/** * ONLYOFFICE Integration Service * * Standard ONLYOFFICE Document Server integration for inline .docx editing. * * Architecture: * - Backend serves .docx files and receives save callbacks from ONLYOFFICE * - JWT-signed config ensures secure communication * - Document key is based on file content hash + timestamp for cache-busting * * Docker Networking: * - Public URL (browser -> ONLYOFFICE): http://:8080 * - Internal URL (backend -> ONLYOFFICE): http://onlyoffice-server/ * - Backend URL (ONLYOFFICE -> backend): http://:8771 */ const express = require('express'); const jwt = require('jsonwebtoken'); const axios = require('axios'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const router = express.Router(); // ============================================================ // CONFIGURATION // ============================================================ const ONLYOFFICE_PUBLIC_URL = process.env.ONLYOFFICE_PUBLIC_URL || 'http://localhost:8080'; const ONLYOFFICE_INTERNAL_URL = process.env.ONLYOFFICE_INTERNAL_URL || ONLYOFFICE_PUBLIC_URL; const BACKEND_PUBLIC_URL = process.env.BACKEND_PUBLIC_URL || 'http://localhost:8771'; const JWT_SECRET = process.env.JWT_SECRET || 'cvapp_onlyoffice_jwt_secret_2026'; const TEMPLATES_DIR = path.join(__dirname, 'carbone-templates'); fs.mkdirSync(TEMPLATES_DIR, { recursive: true }); 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)); } // ============================================================ // HELPERS // ============================================================ function signToken(payload) { return jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); } function verifyToken(token) { try { if (token && token.startsWith('Bearer ')) token = token.substring(7); return jwt.verify(token, JWT_SECRET); } catch { return null; } } /** * Generate a document key. ONLYOFFICE uses this for caching — a new key * means ONLYOFFICE treats it as a new document and fetches the file fresh. * We combine templateId + file modification time + random hash so every * edit session after a save gets a unique key. */ function generateDocKey(templateId, fileMtime) { return `cv_${templateId}_${Math.floor(fileMtime)}_${crypto.randomBytes(4).toString('hex')}`; } // ============================================================ // GET /:templateId/config — JWT-signed editor config // ============================================================ 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' }); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); const fileStats = fs.statSync(template.path); const fileMtime = fileStats.mtimeMs; const docKey = generateDocKey(templateId, fileMtime); const fileName = template.originalName || template.name + '.docx'; const documentUrl = `${BACKEND_PUBLIC_URL}/api/onlyoffice/${templateId}/download?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, hideRightMenu: true, chat: false, feedback: false, goback: false, }, }, documentType: 'text', }; config.token = signToken(config); res.json({ config, onlyofficeUrl: ONLYOFFICE_PUBLIC_URL, templateId, templateName: template.name, }); }); // ============================================================ // GET /:templateId/download — serve .docx to ONLYOFFICE // ============================================================ 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'); fs.createReadStream(template.path).pipe(res); }); // ============================================================ // POST /:templateId/callback — ONLYOFFICE save callback // // Status: 0=editing, 1=ready, 2=saved, 3=save error, 4=closed, 6=force-saved // On status 2 or 6: download the updated file and overwrite the template. // ============================================================ router.post('/:templateId/callback', async (req, res) => { const templateId = req.params.templateId; // Verify JWT if present const authHeader = req.headers['authorization']; if (authHeader) { if (!verifyToken(authHeader)) { console.error('ONLYOFFICE callback: Invalid JWT'); return res.status(403).json({ error: 'Invalid token' }); } } const { status, url: downloadUrl, key } = req.body; console.log(`ONLYOFFICE callback: template=${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' }); if ((status === 2 || status === 6) && downloadUrl) { try { console.log(`Downloading updated template: ${downloadUrl}`); const response = await axios.get(downloadUrl, { responseType: 'arraybuffer', timeout: 30000 }); fs.writeFileSync(template.path, response.data); template.updatedAt = new Date().toISOString(); saveRegistry(registry); console.log(`Template "${template.name}" saved (${response.data.length} bytes)`); // Clear ONLYOFFICE internal cache for this template try { require('child_process').execSync( `docker exec onlyoffice-server find /var/lib/onlyoffice/documentserver/App_Data/cache -name "cv_${templateId}*" -type d -exec rm -rf {} + 2>/dev/null || true`, { timeout: 5000 } ); } catch (e) { /* non-fatal */ } return res.json({ error: 0, key }); } catch (err) { console.error('Save failed:', err.message); return res.status(500).json({ error: 1, message: err.message }); } } return res.json({ error: 0, key }); }); // ============================================================ // GET /health — health check // ============================================================ router.get('/health', async (req, res) => { let onlyofficeStatus = 'offline'; try { const resp = await axios.get(`${ONLYOFFICE_INTERNAL_URL}/healthcheck`, { timeout: 5000 }); onlyofficeStatus = resp.status === 200 ? 'healthy' : 'unhealthy'; } catch { /* offline */ } res.json({ status: 'ok', onlyoffice: onlyofficeStatus, onlyofficePublicUrl: ONLYOFFICE_PUBLIC_URL, jwtEnabled: !!JWT_SECRET, }); }); module.exports = router;