/** * ONLYOFFICE Integration Service * * Handles: * - Serving .docx templates to ONLYOFFICE for inline editing * - Receiving ONLYOFFICE save callbacks (status=2 -> download updated file) * - Generating JWT-signed editor config for the browser * - Managing the "Edit in ONLYOFFICE" session lifecycle * * Docker Networking: * - Public URL (browser -> ONLYOFFICE): http://:8080/ * - Internal URL (backend -> ONLYOFFICE): http://onlyoffice-server/ * - The backend serves the .docx file at a publicly-accessible URL so * ONLYOFFICE can fetch it, and receives callbacks at a publicly-accessible URL. * * JWT Security: * - The JWT_SECRET env var is shared between this backend and the ONLYOFFICE container. * - ALL communication (editor config, callbacks) is signed with this secret. * - ONLYOFFICE validates the JWT on incoming requests and signs outgoing callbacks. */ const express = require('express'); const jwt = require('jsonwebtoken'); const axios = require('axios'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const router = express.Router(); // ============================================================ // CONFIGURATION // ============================================================ /** * ONLYOFFICE URLs - these are different for browser vs backend: * * ONLYOFFICE_PUBLIC_URL: The URL the user's BROWSER uses to load the ONLYOFFICE * JavaScript API (e.g., http://100.91.25.139:8080/ or https://docs.example.com/) * This is set via env var and must be accessible from the user's browser. * * ONLYOFFICE_INTERNAL_URL: The URL our BACKEND uses to talk to ONLYOFFICE * over the Docker network (e.g., http://onlyoffice-server/). * In dev mode (no Docker), this is the same as the public URL. */ const ONLYOFFICE_PUBLIC_URL = process.env.ONLYOFFICE_PUBLIC_URL || 'http://localhost:8080'; const ONLYOFFICE_INTERNAL_URL = process.env.ONLYOFFICE_INTERNAL_URL || ONLYOFFICE_PUBLIC_URL; /** * BACKEND_PUBLIC_URL: The URL that ONLYOFFICE uses to fetch the .docx template * and send callbacks back to. This must be accessible from the ONLYOFFICE * container's network perspective. * - In Docker: http://cv-backend:8771 (internal Docker DNS) * - In dev: http://:8771 (same machine) */ const BACKEND_PUBLIC_URL = process.env.BACKEND_PUBLIC_URL || 'http://localhost:8771'; /** * JWT_SECRET: Shared secret between this backend and ONLYOFFICE. * Must be identical in both containers for JWT validation to work. */ const JWT_SECRET = process.env.JWT_SECRET || 'cvapp_onlyoffice_jwt_secret_2026'; // Template storage (shared with carbone-service) const TEMPLATES_DIR = path.join(__dirname, 'carbone-templates'); fs.mkdirSync(TEMPLATES_DIR, { recursive: true }); // Load registry (shared with carbone-service) const REGISTRY_PATH = path.join(TEMPLATES_DIR, 'registry.json'); function loadRegistry() { try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf-8')); } catch { return []; } } function saveRegistry(registry) { fs.writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2)); } // ============================================================ // JWT TOKEN HELPERS // ============================================================ /** * Sign a payload with the JWT secret. * ONLYOFFICE expects the token in the Authorization header as "Bearer ". */ function signToken(payload) { return jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); } /** * Verify a JWT token from ONLYOFFICE callbacks. * ONLYOFFICE sends the token in the Authorization header. */ function verifyToken(token) { try { // Remove "Bearer " prefix if present if (token && token.startsWith('Bearer ')) { token = token.substring(7); } return jwt.verify(token, JWT_SECRET); } catch (err) { return null; } } /** * Generate a unique document key. * ONLYOFFICE uses this to identify document sessions and for caching. * Must be unique per document + version (so re-edits get fresh sessions). */ function generateDocKey(templateId, fileMtime) { // Include the file's modification time in the key so that // when the file is updated via callback, the next edit session // gets a completely new key and ONLYOFFICE fetches the fresh file const mtime = fileMtime || Date.now(); return `cvtemplate_${templateId}_${mtime}_${crypto.randomBytes(4).toString('hex')}`; } // ============================================================ // ENDPOINT: GET /api/onlyoffice/:templateId/config // Returns the JWT-signed editor config for the browser to initialize // ============================================================ router.get('/:templateId/config', (req, res) => { const templateId = req.params.templateId; const registry = loadRegistry(); const template = registry.find(t => t.id === templateId); if (!template) { return res.status(404).json({ error: 'Template not found' }); } if (!fs.existsSync(template.path)) { return res.status(404).json({ error: 'Template file missing' }); } // Prevent browser caching of the config — always return fresh config res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); // Get file modification time for cache-busting doc key const fileStats = fs.statSync(template.path); const fileMtime = fileStats.mtimeMs; // Use Date.now() as the primary uniqueness driver — guaranteed fresh every time const docKey = generateDocKey(templateId, Date.now()); const fileName = template.originalName || template.name + '.docx'; // Add file mtime as cache-buster to the document URL so ONLYOFFICE // always fetches the latest version from our backend. // The mtime changes every time the file is saved, so the URL is // different after each save, forcing ONLYOFFICE to re-download. const documentUrl = `${BACKEND_PUBLIC_URL}/api/onlyoffice/${templateId}/download?mtime=${fileMtime}&cb=${Date.now()}`; const callbackUrl = `${BACKEND_PUBLIC_URL}/api/onlyoffice/${templateId}/callback`; const config = { document: { fileType: 'docx', key: docKey, title: fileName, url: documentUrl, permissions: { edit: true, download: true, review: true, print: false, }, }, editorConfig: { mode: 'edit', lang: 'en', callbackUrl: callbackUrl, user: { id: 'cv-app-user', name: 'CV App Editor', }, customization: { autosave: true, forcesave: true, compactHeader: false, toolbarNoTabs: false, // Hide features not relevant to CV templates hideRightMenu: true, hideRulers: false, // Remove ONLYOFFICE branding/logo from the editor header logo: { visible: false, }, // Hide the "Powered by ONLYOFFICE" footer customer: { name: 'CV Application', info: '', address: '', mail: '', www: '', logo: '', logoVisible: false, }, // Hide chat and feedback (not needed for our use case) chat: false, feedback: false, forcesave: true, goback: false, }, }, // The type of document editor to load documentType: 'text', }; // Sign the entire config with JWT // ONLYOFFICE validates this token when the editor loads const token = signToken(config); config.token = token; res.json({ config: config, onlyofficeUrl: ONLYOFFICE_PUBLIC_URL, templateId: templateId, templateName: template.name, }); }); // ============================================================ // ENDPOINT: GET /api/onlyoffice/:templateId/download // ONLYOFFICE fetches the .docx file from this URL // ============================================================ router.get('/:templateId/download', (req, res) => { const templateId = req.params.templateId; const registry = loadRegistry(); const template = registry.find(t => t.id === templateId); if (!template) { return res.status(404).json({ error: 'Template not found' }); } if (!fs.existsSync(template.path)) { return res.status(404).json({ error: 'Template file missing' }); } const fileName = template.originalName || template.name + '.docx'; res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); const fileStream = fs.createReadStream(template.path); fileStream.pipe(res); }); // ============================================================ // ENDPOINT: POST /api/onlyoffice/:templateId/callback // // ONLYOFFICE calls this endpoint when the document state changes. // Status codes: // 0 = Document being edited (no action needed) // 1 = Document ready for saving (prepare to receive) // 2 = Document saved, URL contains the updated file (DOWNLOAD AND OVERWRITE) // 3 = Document save error // 4 = Document closed with no changes (no action needed) // 6 = Document is being edited but force-save was requested // 7 = Force-save error // // When status === 2 or 6, the payload contains a "url" field with a // direct download link to the updated .docx file. We fetch it and // overwrite the stored template. // ============================================================ router.post('/:templateId/callback', async (req, res) => { const templateId = req.params.templateId; // ONLYOFFICE sends JWT in the Authorization header // Verify the token for security const authHeader = req.headers['authorization']; if (authHeader) { const decoded = verifyToken(authHeader); if (!decoded) { console.error('ONLYOFFICE callback: Invalid JWT token'); return res.status(403).json({ error: 'Invalid token' }); } } // The callback payload from ONLYOFFICE const payload = req.body; const status = payload.status; const downloadUrl = payload.url; const key = payload.key; console.log(`ONLYOFFICE callback: templateId=${templateId}, status=${status}, key=${key}`); const registry = loadRegistry(); const template = registry.find(t => t.id === templateId); if (!template) { return res.status(404).json({ error: 'Template not found' }); } // Status 2 = saved, 6 = force-saved -> download the updated file if ((status === 2 || status === 6) && downloadUrl) { try { console.log(`Downloading updated template from ONLYOFFICE: ${downloadUrl}`); // Fetch the updated .docx file from ONLYOFFICE's URL const response = await axios.get(downloadUrl, { responseType: 'arraybuffer', timeout: 30000, }); // Overwrite the stored template file const templatePath = template.path; fs.writeFileSync(templatePath, response.data); // Update the registry timestamp template.updatedAt = new Date().toISOString(); saveRegistry(registry); console.log(`Template "${template.name}" updated successfully (${response.data.length} bytes)`); // Clear ONLYOFFICE's internal document cache for this template // so the next edit session fetches the fresh file try { const { execSync } = require('child_process'); execSync(`docker exec onlyoffice-server find /var/lib/onlyoffice/documentserver/App_Data/cache -name "cvtemplate_${templateId}*" -type d -exec rm -rf {} + 2>/dev/null || true`, { timeout: 5000 }); console.log('ONLYOFFICE cache cleared for template:', templateId); } catch (cacheErr) { // Non-fatal — cache will expire naturally console.log('Cache clear skipped:', cacheErr.message.substring(0, 100)); } // ONLYOFFICE expects a JSON response acknowledging the save return res.json({ error: 0, key: key }); } catch (err) { console.error('Failed to download updated template:', err.message); return res.status(500).json({ error: 1, message: 'Failed to save: ' + err.message }); } } // Status 4 = closed without changes, 0 = still editing, etc. // Just acknowledge return res.json({ error: 0, key: key }); }); // ============================================================ // ENDPOINT: GET /api/onlyoffice/health // Health check for the ONLYOFFICE integration // ============================================================ router.get('/health', async (req, res) => { let onlyofficeStatus = 'unknown'; try { const resp = await axios.get(`${ONLYOFFICE_INTERNAL_URL}/healthcheck`, { timeout: 5000 }); onlyofficeStatus = resp.status === 200 ? 'healthy' : 'unhealthy'; } catch (err) { onlyofficeStatus = 'offline'; } res.json({ status: 'ok', onlyoffice: onlyofficeStatus, onlyofficePublicUrl: ONLYOFFICE_PUBLIC_URL, onlyofficeInternalUrl: ONLYOFFICE_INTERNAL_URL, backendUrl: BACKEND_PUBLIC_URL, jwtEnabled: !!JWT_SECRET, }); }); module.exports = router;