refactor: clean ONLYOFFICE integration - remove workarounds, follow standard pattern

This commit is contained in:
root
2026-07-17 08:56:38 +00:00
parent 7883c46b15
commit a962f29f55
4 changed files with 92 additions and 324 deletions

View File

@@ -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,
});
});

View File

@@ -694,13 +694,3 @@ function formatDate(d) {
// Initial load
loadDashboard();
// Restore active page after an ONLYOFFICE editor close triggered a page reload
(function() {
var returnPage = sessionStorage.getItem('returnPage');
if (returnPage) {
sessionStorage.removeItem('returnPage');
var link = document.querySelector('[data-page="' + returnPage + '"]');
if (link) link.click();
}
})();

View File

@@ -263,56 +263,34 @@ async function loadOnlyofficeTemplates() {
// Force save the document
function forceSaveOnlyoffice() {
if (onlyofficeEditorInstance) {
// The ONLYOFFICE editor's onSave event will trigger the callback
// which saves the file to our backend
toast('Saving... changes will be stored automatically');
}
}
// Close the editor and clean up
// Close the editor — standard ONLYOFFICE teardown
function closeOnlyofficeEditor() {
if (onlyofficeEditorInstance) {
try {
onlyofficeEditorInstance.destroyEditor();
} catch (e) {
console.error('Error closing editor:', e);
}
try { onlyofficeEditorInstance.destroyEditor(); } catch (e) {}
onlyofficeEditorInstance = null;
}
closeModal();
// Reload the page to clear ONLYOFFICE's in-memory state.
// Save the active page so the user returns to Templates automatically
sessionStorage.setItem('returnPage', 'templates');
// Wait 2 seconds for the save callback (status=2) to complete
// on our backend before reloading the page
setTimeout(() => {
location.reload();
}, 2000);
loadOnlyofficeTemplates();
}
// Open ONLYOFFICE editor in a modal
async function openOnlyofficeEditor(templateId, templateName) {
// If there's a previous editor instance still lingering, destroy it first
if (onlyofficeEditorInstance) {
try { onlyofficeEditorInstance.destroyEditor(); } catch (e) {}
onlyofficeEditorInstance = null;
}
try {
// Get the JWT-signed editor config from our backend
// Add cache-buster to prevent browser caching the old config
const cacheBuster = Date.now();
const resp = await fetch(ONLYOFFICE_API + '/' + templateId + '/config?cb=' + cacheBuster);
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || 'Failed to get editor config');
}
const data = await resp.json();
const config = data.config;
const onlyofficeUrl = data.onlyofficeUrl;
// Fetch JWT-signed config from backend (cache-busted)
const resp = await fetch(ONLYOFFICE_API + '/' + templateId + '/config?cb=' + Date.now());
if (!resp.ok) throw new Error((await resp.json()).error || 'Failed to get config');
const { config, onlyofficeUrl } = await resp.json();
// Build the editor modal with a unique container ID each time
// so ONLYOFFICE never reuses a cached DOM element
// Unique container ID per session
const containerId = 'oo-editor-' + Date.now();
const body = `
<div class="flex-between mb-16">
@@ -327,26 +305,19 @@ async function openOnlyofficeEditor(templateId, templateName) {
`;
showModal(body, '', 'xlarge');
// Load ONLYOFFICE API script - force fresh load each time
// Remove old script to force ONLYOFFICE to reload API.js from server
const oldScript = document.getElementById('onlyoffice-api-script');
if (oldScript) oldScript.remove();
// Reload with cache-buster so ONLYOFFICE gets fresh API state
const scriptBuster = Date.now();
const scriptUrl = onlyofficeUrl + '/web-apps/apps/api/documents/api.js?cb=' + scriptBuster;
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.id = 'onlyoffice-api-script';
script.src = scriptUrl;
script.onload = resolve;
script.onerror = () => reject(new Error('Failed to load ONLYOFFICE API from ' + scriptUrl));
document.head.appendChild(script);
// Load ONLYOFFICE API script (once)
if (typeof DocsAPI === 'undefined') {
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = onlyofficeUrl + '/web-apps/apps/api/documents/api.js';
s.onload = resolve;
s.onerror = () => reject(new Error('Failed to load ONLYOFFICE API'));
document.head.appendChild(s);
});
}
// Initialize the ONLYOFFICE editor
if (typeof DocsAPI === 'undefined' || !DocsAPI.DocEditor) {
throw new Error('ONLYOFFICE API not loaded. Check that the server is running at ' + onlyofficeUrl);
throw new Error('ONLYOFFICE API not loaded');
}
onlyofficeEditorInstance = new DocsAPI.DocEditor(containerId, {
@@ -354,30 +325,11 @@ async function openOnlyofficeEditor(templateId, templateName) {
width: '100%',
height: '800px',
events: {
onAppReady: function() {
console.log('ONLYOFFICE editor ready');
},
onDocumentReady: function() {
console.log('Document loaded in editor');
// Inject CSS into ONLYOFFICE iframe to hide the logo
try {
var iframe = document.getElementById(containerId).querySelector('iframe');
if (iframe && iframe.contentDocument) {
var style = iframe.contentDocument.createElement('style');
style.textContent = '#header-logo { display: none !important; }';
iframe.contentDocument.head.appendChild(style);
}
} catch(e) {
console.log('Could not inject CSS into iframe:', e);
}
},
onSave: function(event) {
toast('Document saved successfully');
// Reload the templates list
onSave: function() {
toast('Document saved');
loadOnlyofficeTemplates();
},
onError: function(event) {
console.error('ONLYOFFICE error:', event);
toast('Editor error: ' + (event.data?.errorDescription || 'unknown'), 'error');
},
},
@@ -386,21 +338,22 @@ async function openOnlyofficeEditor(templateId, templateName) {
toast('Failed to open editor: ' + e.message, 'error');
}
}
// Initialize when templates page is active
document.addEventListener('DOMContentLoaded', () => {
const templatesPage = document.getElementById('page-templates');
if (templatesPage) {
// Check on initial load (page may already be active from sessionStorage restore)
if (!templatesPage) return;
if (templatesPage.classList.contains('active')) {
checkOnlyofficeStatus();
loadOnlyofficeTemplates();
}
const observer = new MutationObserver(() => {
if (templatesPage.classList.contains('active')) {
checkOnlyofficeStatus();
loadOnlyofficeTemplates();
}
// Also watch for future tab switches
const observer = new MutationObserver(() => {
if (templatesPage.classList.contains('active')) {
checkOnlyofficeStatus();
loadOnlyofficeTemplates();
}
});
observer.observe(templatesPage, { attributes: true, attributeFilter: ['class'] });
}
});
observer.observe(templatesPage, { attributes: true, attributeFilter: ['class'] });
});

View File

@@ -139,7 +139,7 @@
<!-- Modals -->
<div class="modal-overlay" id="modal-overlay"></div>
<script src="/static/app.js?v=10"></script>
<script src="/static/carbone.js?v=10"></script>
<script src="/static/app.js?v=11"></script>
<script src="/static/carbone.js?v=11"></script>
</body>
</html>