refactor: clean ONLYOFFICE integration - remove workarounds, follow standard pattern
This commit is contained in:
@@ -1,28 +1,22 @@
|
||||
/**
|
||||
* 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
|
||||
* 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/
|
||||
* - Public URL (browser -> ONLYOFFICE): http://<host>: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.
|
||||
* - Backend URL (ONLYOFFICE -> backend): http://<host>:8771
|
||||
*/
|
||||
|
||||
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');
|
||||
@@ -33,40 +27,14 @@ 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://<host>: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')); }
|
||||
@@ -77,49 +45,32 @@ function saveRegistry(registry) {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// JWT TOKEN HELPERS
|
||||
// HELPERS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Sign a payload with the JWT secret.
|
||||
* ONLYOFFICE expects the token in the Authorization header as "Bearer <token>".
|
||||
*/
|
||||
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);
|
||||
}
|
||||
if (token && token.startsWith('Bearer ')) token = token.substring(7);
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
} catch { 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).
|
||||
* 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) {
|
||||
// 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')}`;
|
||||
return `cv_${templateId}_${Math.floor(fileMtime)}_${crypto.randomBytes(4).toString('hex')}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ENDPOINT: GET /api/onlyoffice/:templateId/config
|
||||
// Returns the JWT-signed editor config for the browser to initialize
|
||||
// GET /:templateId/config — JWT-signed editor config
|
||||
// ============================================================
|
||||
|
||||
router.get('/:templateId/config', (req, res) => {
|
||||
@@ -127,31 +78,19 @@ router.get('/:templateId/config', (req, res) => {
|
||||
const registry = loadRegistry();
|
||||
const template = registry.find(t => t.id === templateId);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
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' });
|
||||
|
||||
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 docKey = generateDocKey(templateId, fileMtime);
|
||||
const fileName = template.originalName || template.name + '.docx';
|
||||
|
||||
// Add the doc key to the download URL path so ONLYOFFICE treats
|
||||
// every edit session as a completely fresh document URL.
|
||||
// Query params alone aren't enough — ONLYOFFICE may ignore them for caching.
|
||||
const keyHash = docKey.split('_').pop();
|
||||
const documentUrl = `${BACKEND_PUBLIC_URL}/api/onlyoffice/${templateId}/download/${keyHash}?mtime=${fileMtime}&cb=${Date.now()}`;
|
||||
const documentUrl = `${BACKEND_PUBLIC_URL}/api/onlyoffice/${templateId}/download?cb=${Date.now()}`;
|
||||
const callbackUrl = `${BACKEND_PUBLIC_URL}/api/onlyoffice/${templateId}/callback`;
|
||||
|
||||
const config = {
|
||||
@@ -160,110 +99,47 @@ router.get('/:templateId/config', (req, res) => {
|
||||
key: docKey,
|
||||
title: fileName,
|
||||
url: documentUrl,
|
||||
permissions: {
|
||||
edit: true,
|
||||
download: true,
|
||||
review: true,
|
||||
print: false,
|
||||
},
|
||||
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',
|
||||
},
|
||||
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;
|
||||
config.token = signToken(config);
|
||||
|
||||
res.json({
|
||||
config: config,
|
||||
config,
|
||||
onlyofficeUrl: ONLYOFFICE_PUBLIC_URL,
|
||||
templateId: templateId,
|
||||
templateId,
|
||||
templateName: template.name,
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// ENDPOINT: GET /api/onlyoffice/:templateId/download
|
||||
// ONLYOFFICE fetches the .docx file from this URL.
|
||||
// The optional :keyHash path param provides per-session cache-busting.
|
||||
// GET /:templateId/download — serve .docx to ONLYOFFICE
|
||||
// ============================================================
|
||||
|
||||
router.get('/:templateId/download/:keyHash', (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);
|
||||
});
|
||||
|
||||
// Fallback route without keyHash (backward compatibility)
|
||||
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' });
|
||||
}
|
||||
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');
|
||||
@@ -272,131 +148,80 @@ router.get('/:templateId/download', (req, res) => {
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
|
||||
const fileStream = fs.createReadStream(template.path);
|
||||
fileStream.pipe(res);
|
||||
fs.createReadStream(template.path).pipe(res);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// ENDPOINT: POST /api/onlyoffice/:templateId/callback
|
||||
// POST /:templateId/callback — ONLYOFFICE save 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.
|
||||
// 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;
|
||||
|
||||
// ONLYOFFICE sends JWT in the Authorization header
|
||||
// Verify the token for security
|
||||
// Verify JWT if present
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (authHeader) {
|
||||
const decoded = verifyToken(authHeader);
|
||||
if (!decoded) {
|
||||
console.error('ONLYOFFICE callback: Invalid JWT token');
|
||||
if (!verifyToken(authHeader)) {
|
||||
console.error('ONLYOFFICE callback: Invalid JWT');
|
||||
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;
|
||||
const { status, url: downloadUrl, key } = req.body;
|
||||
|
||||
console.log(`ONLYOFFICE callback: templateId=${templateId}, status=${status}, key=${key}`);
|
||||
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 (!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}`);
|
||||
console.log(`Downloading updated template: ${downloadUrl}`);
|
||||
const response = await axios.get(downloadUrl, { responseType: 'arraybuffer', timeout: 30000 });
|
||||
|
||||
// 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
|
||||
fs.writeFileSync(template.path, response.data);
|
||||
template.updatedAt = new Date().toISOString();
|
||||
saveRegistry(registry);
|
||||
|
||||
console.log(`Template "${template.name}" updated successfully (${response.data.length} bytes)`);
|
||||
console.log(`Template "${template.name}" saved (${response.data.length} bytes)`);
|
||||
|
||||
// Clear ONLYOFFICE's internal document cache for this template
|
||||
// so the next edit session fetches the fresh file
|
||||
// Clear ONLYOFFICE internal cache for this template
|
||||
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));
|
||||
}
|
||||
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 */ }
|
||||
|
||||
// ONLYOFFICE expects a JSON response acknowledging the save
|
||||
return res.json({
|
||||
error: 0,
|
||||
key: key
|
||||
});
|
||||
return res.json({ error: 0, 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
|
||||
});
|
||||
console.error('Save failed:', err.message);
|
||||
return res.status(500).json({ error: 1, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Status 4 = closed without changes, 0 = still editing, etc.
|
||||
// Just acknowledge
|
||||
return res.json({
|
||||
error: 0,
|
||||
key: key
|
||||
});
|
||||
return res.json({ error: 0, key });
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// ENDPOINT: GET /api/onlyoffice/health
|
||||
// Health check for the ONLYOFFICE integration
|
||||
// GET /health — health check
|
||||
// ============================================================
|
||||
|
||||
router.get('/health', async (req, res) => {
|
||||
let onlyofficeStatus = 'unknown';
|
||||
let onlyofficeStatus = 'offline';
|
||||
try {
|
||||
const resp = await axios.get(`${ONLYOFFICE_INTERNAL_URL}/healthcheck`, { timeout: 5000 });
|
||||
onlyofficeStatus = resp.status === 200 ? 'healthy' : 'unhealthy';
|
||||
} catch (err) {
|
||||
onlyofficeStatus = 'offline';
|
||||
}
|
||||
} catch { /* offline */ }
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
onlyoffice: onlyofficeStatus,
|
||||
onlyofficePublicUrl: ONLYOFFICE_PUBLIC_URL,
|
||||
onlyofficeInternalUrl: ONLYOFFICE_INTERNAL_URL,
|
||||
backendUrl: BACKEND_PUBLIC_URL,
|
||||
jwtEnabled: !!JWT_SECRET,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user