diff --git a/renderer/onlyoffice-service.js b/renderer/onlyoffice-service.js index 7906b19..c982fec 100644 --- a/renderer/onlyoffice-service.js +++ b/renderer/onlyoffice-service.js @@ -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://:8080/ + * - 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. + * - Backend URL (ONLYOFFICE -> backend): http://: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://: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 ". - */ 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, }); }); diff --git a/static/app.js b/static/app.js index 68eb1d2..2b2568c 100644 --- a/static/app.js +++ b/static/app.js @@ -693,14 +693,4 @@ 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(); - } -})(); \ No newline at end of file +loadDashboard(); \ No newline at end of file diff --git a/static/carbone.js b/static/carbone.js index 63f2fc1..ea9eb03 100644 --- a/static/carbone.js +++ b/static/carbone.js @@ -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 = `
@@ -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'] }); }); \ No newline at end of file diff --git a/static/index.html b/static/index.html index 7abe3f6..4ee8571 100644 --- a/static/index.html +++ b/static/index.html @@ -139,7 +139,7 @@ - - + + \ No newline at end of file