229 lines
8.0 KiB
JavaScript
229 lines
8.0 KiB
JavaScript
/**
|
|
* 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://<host>:8080
|
|
* - Internal URL (backend -> ONLYOFFICE): http://onlyoffice-server/
|
|
* - Backend URL (ONLYOFFICE -> backend): http://<host>: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; |