diff --git a/.env.docker b/.env.docker new file mode 100644 index 0000000..18b95d6 --- /dev/null +++ b/.env.docker @@ -0,0 +1 @@ +JWT_SECRET=cvapp_onlyoffice_jwt_secret_2026 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d4e8119 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,97 @@ +# ============================================================ +# CV Application - Docker Compose +# +# Containers: +# cv-backend - FastAPI + Node Carbone service (existing code, containerized) +# cv-postgres - PostgreSQL database +# onlyoffice-server - ONLYOFFICE Document Server (inline .docx editing) +# +# Networking: +# - Internal Docker network: cvnet +# - Backend talks to ONLYOFFICE via http://onlyoffice-server/ (internal) +# - Browser talks to ONLYOFFICE via http://:8080/ (public) +# - JWT tokens secure all document session communication +# ============================================================ + +version: "3.8" + +services: + # PostgreSQL Database + cv-postgres: + image: postgres:16-alpine + container_name: cv-postgres + restart: unless-stopped + environment: + POSTGRES_DB: cvapp + POSTGRES_USER: cvapp + POSTGRES_PASSWORD: cvapp2026 + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --template=template0" + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - cvnet + ports: + - "5433:5432" # Different host port to avoid conflict with local PG + + # ONLYOFFICE Document Server + onlyoffice-server: + image: onlyoffice/documentserver:latest + container_name: onlyoffice-server + restart: unless-stopped + environment: + # JWT Security - shared secret with backend + JWT_ENABLED: "true" + JWT_SECRET: "${JWT_SECRET:-cvapp_onlyoffice_jwt_secret_2026}" + JWT_HEADER: "Authorization" + JWT_IN_BODY: "true" + # Disable example apps (not needed in production) + ONLYOFFICE_EXAMPLE: "false" + volumes: + - onlyoffice_data:/var/www/onlyoffice/Data + - onlyoffice_logs:/var/log/onlyoffice + - onlyoffice_cache:/var/lib/onlyoffice + networks: + - cvnet + ports: + - "8080:80" # Public URL for browser to load ONLYOFFICE JS API + depends_on: + - cv-postgres + + # CV Application Backend (FastAPI + Carbone Express) + # We keep this as the existing systemd service for now, but this config + # shows how to containerize it. The backend talks to ONLYOFFICE internally. + # cv-backend: + # build: . + # container_name: cv-backend + # restart: unless-stopped + # environment: + # DB_HOST: cv-postgres + # DB_PORT: 5432 + # DB_NAME: cvapp + # DB_USER: cvapp + # DB_PASSWORD: cvapp2026 + # OLLAMA_API_KEY: "${OLLAMA_API_KEY}" + # JWT_SECRET: "${JWT_SECRET:-cvapp_onlyoffice_jwt_secret_2026}" + # ONLYOFFICE_INTERNAL_URL: "http://onlyoffice-server/" + # ONLYOFFICE_PUBLIC_URL: "http://localhost:8080/" + # volumes: + # - ./uploads:/app/uploads + # - ./renderer/carbone-templates:/app/carbone-templates + # networks: + # - cvnet + # ports: + # - "8770:8770" # FastAPI + # - "8771:8771" # Carbone Express + # depends_on: + # - cv-postgres + # - onlyoffice-server + +volumes: + pgdata: + onlyoffice_data: + onlyoffice_logs: + onlyoffice_cache: + +networks: + cvnet: + driver: bridge \ No newline at end of file diff --git a/renderer/carbone-server.js b/renderer/carbone-server.js index 35ca591..037027c 100644 --- a/renderer/carbone-server.js +++ b/renderer/carbone-server.js @@ -1,11 +1,13 @@ /** * Carbone Express Server - Standalone microservice * Runs on port 8771 alongside the FastAPI app on 8770 + * Also serves ONLYOFFICE callback endpoints */ const express = require('express'); const cors = require('cors'); const path = require('path'); const { router } = require('./carbone-service'); +const onlyofficeRouter = require('./onlyoffice-service'); const app = express(); const PORT = 8771; @@ -16,9 +18,12 @@ app.use(express.json({ limit: '50mb' })); // Mount Carbone routes app.use('/api/carbone', router); +// Mount ONLYOFFICE routes +app.use('/api/onlyoffice', onlyofficeRouter); + // Health check -app.get('/health', (req, res) => res.json({ status: 'ok', service: 'carbone' })); +app.get('/health', (req, res) => res.json({ status: 'ok', service: 'carbone+onlyoffice' })); app.listen(PORT, '0.0.0.0', () => { - console.log(`Carbone service running on http://0.0.0.0:${PORT}`); + console.log(`Carbone + ONLYOFFICE service running on http://0.0.0.0:${PORT}`); }); \ No newline at end of file diff --git a/renderer/onlyoffice-service.js b/renderer/onlyoffice-service.js new file mode 100644 index 0000000..fa57b2f --- /dev/null +++ b/renderer/onlyoffice-service.js @@ -0,0 +1,332 @@ +/** + * 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 + * + * Docker Networking: + * - 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. + */ + +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'); + +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')); } + catch { return []; } +} +function saveRegistry(registry) { + fs.writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2)); +} + +// ============================================================ +// JWT TOKEN 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); + } + return jwt.verify(token, JWT_SECRET); + } catch (err) { + 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). + */ +function generateDocKey(templateId) { + return `cvtemplate_${templateId}_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`; +} + +// ============================================================ +// ENDPOINT: GET /api/onlyoffice/:templateId/config +// Returns the JWT-signed editor config for the browser to initialize +// ============================================================ + +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' }); + } + + const docKey = generateDocKey(templateId); + const fileName = template.originalName || template.name + '.docx'; + + // Build the editor config + // The document URL must be accessible from ONLYOFFICE's perspective. + // In Docker: http://cv-backend:8771/api/onlyoffice//download + // In dev: http://:8771/api/onlyoffice//download + const documentUrl = `${BACKEND_PUBLIC_URL}/api/onlyoffice/${templateId}/download`; + 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, + toolbarNoTabs: false, + // Hide features not relevant to CV templates + hideRightMenu: true, + hideRulers: 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; + + res.json({ + config: config, + onlyofficeUrl: ONLYOFFICE_PUBLIC_URL, + templateId: templateId, + templateName: template.name, + }); +}); + +// ============================================================ +// ENDPOINT: GET /api/onlyoffice/:templateId/download +// ONLYOFFICE fetches the .docx file from this URL +// ============================================================ + +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}"`); + + const fileStream = fs.createReadStream(template.path); + fileStream.pipe(res); +}); + +// ============================================================ +// ENDPOINT: POST /api/onlyoffice/:templateId/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. +// ============================================================ + +router.post('/:templateId/callback', async (req, res) => { + const templateId = req.params.templateId; + + // ONLYOFFICE sends JWT in the Authorization header + // Verify the token for security + const authHeader = req.headers['authorization']; + if (authHeader) { + const decoded = verifyToken(authHeader); + if (!decoded) { + console.error('ONLYOFFICE callback: Invalid JWT token'); + 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; + + console.log(`ONLYOFFICE callback: templateId=${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' }); + } + + // 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}`); + + // 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 + template.updatedAt = new Date().toISOString(); + saveRegistry(registry); + + console.log(`Template "${template.name}" updated successfully (${response.data.length} bytes)`); + + // ONLYOFFICE expects a JSON response acknowledging the save + return res.json({ + error: 0, + key: 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 + }); + } + } + + // Status 4 = closed without changes, 0 = still editing, etc. + // Just acknowledge + return res.json({ + error: 0, + key: key + }); +}); + +// ============================================================ +// ENDPOINT: GET /api/onlyoffice/health +// Health check for the ONLYOFFICE integration +// ============================================================ + +router.get('/health', async (req, res) => { + let onlyofficeStatus = 'unknown'; + try { + const resp = await axios.get(`${ONLYOFFICE_INTERNAL_URL}/healthcheck`, { timeout: 5000 }); + onlyofficeStatus = resp.status === 200 ? 'healthy' : 'unhealthy'; + } catch (err) { + onlyofficeStatus = 'offline'; + } + + res.json({ + status: 'ok', + onlyoffice: onlyofficeStatus, + onlyofficePublicUrl: ONLYOFFICE_PUBLIC_URL, + onlyofficeInternalUrl: ONLYOFFICE_INTERNAL_URL, + backendUrl: BACKEND_PUBLIC_URL, + jwtEnabled: !!JWT_SECRET, + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/renderer/package-lock.json b/renderer/package-lock.json index cadc209..99cd135 100644 --- a/renderer/package-lock.json +++ b/renderer/package-lock.json @@ -10,9 +10,11 @@ "license": "ISC", "dependencies": { "archiver": "^8.0.0", + "axios": "^1.18.1", "carbone": "^3.8.2", "cors": "^2.8.6", "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", "multer": "^2.2.0", "puppeteer": "^25.3.0", "uuid": "^14.0.1" @@ -96,6 +98,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -177,6 +191,24 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -434,6 +466,12 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -538,6 +576,18 @@ "node": ">=20" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/compress-commons": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", @@ -712,6 +762,15 @@ } } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -741,6 +800,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -792,6 +860,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -987,6 +1070,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1096,6 +1215,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1128,6 +1262,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -1212,6 +1359,49 @@ "node": ">=20" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -1266,6 +1456,48 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1485,6 +1717,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/puppeteer": { "version": "25.3.0", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.3.0.tgz", @@ -1638,6 +1879,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", diff --git a/renderer/package.json b/renderer/package.json index b2cb7ca..55c85e8 100644 --- a/renderer/package.json +++ b/renderer/package.json @@ -11,9 +11,11 @@ "license": "ISC", "dependencies": { "archiver": "^8.0.0", + "axios": "^1.18.1", "carbone": "^3.8.2", "cors": "^2.8.6", "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", "multer": "^2.2.0", "puppeteer": "^25.3.0", "uuid": "^14.0.1" diff --git a/static/carbone.js b/static/carbone.js index f419284..a09f6b8 100644 --- a/static/carbone.js +++ b/static/carbone.js @@ -183,4 +183,189 @@ async function viewTagsHelp() { // ============================================================ function downloadHelpPdf() { window.open(CARBONE_API + '/help-pdf', '_blank'); -} \ No newline at end of file +} + +// ============================================================ +// ONLYOFFICE INLINE EDITOR +// ============================================================ + +const ONLYOFFICE_API = window.location.protocol + '//' + window.location.hostname + ':8771/api/onlyoffice'; +let onlyofficeEditorInstance = null; + +// Check ONLYOFFICE status when templates page loads +async function checkOnlyofficeStatus() { + const badge = document.getElementById('onlyoffice-status'); + const list = document.getElementById('onlyoffice-templates-list'); + if (!badge) return; + + try { + const resp = await fetch(ONLYOFFICE_API + '/health'); + const data = await resp.json(); + if (data.onlyoffice === 'healthy') { + badge.className = 'badge badge-green'; + badge.textContent = 'online'; + } else { + badge.className = 'badge badge-red'; + badge.textContent = data.onlyoffice === 'offline' ? 'offline' : data.onlyoffice; + if (list) list.innerHTML = '

ONLYOFFICE server is not running. Start it with: cd /root/workspace/cv-app && docker compose up -d onlyoffice-server

'; + } + } catch (e) { + badge.className = 'badge badge-red'; + badge.textContent = 'error'; + if (list) list.innerHTML = '

Cannot connect to ONLYOFFICE service. Make sure the Carbone+ONLYOFFICE backend is running.

'; + } +} + +// Load templates list for ONLYOFFICE editing +async function loadOnlyofficeTemplates() { + const list = document.getElementById('onlyoffice-templates-list'); + if (!list) return; + + // Check if ONLYOFFICE is online first + try { + const healthResp = await fetch(ONLYOFFICE_API + '/health'); + const health = await healthResp.json(); + if (health.onlyoffice !== 'healthy') { + list.innerHTML = '

ONLYOFFICE server is ' + health.onlyoffice + '. Start the container to enable inline editing.

'; + return; + } + } catch (e) { + list.innerHTML = '

Cannot reach ONLYOFFICE service.

'; + return; + } + + // Load templates from Carbone registry + try { + const resp = await fetch(CARBONE_API + '/templates'); + const data = await resp.json(); + if (!data.templates || !data.templates.length) { + list.innerHTML = '

No templates uploaded yet. Upload a .docx template first to edit it inline.

'; + return; + } + list.innerHTML = data.templates.map(t => ` +
+
+ ${t.name} +
${t.originalName} · Updated: ${t.updatedAt ? new Date(t.updatedAt).toLocaleString() : new Date(t.uploadedAt).toLocaleString()}
+
+
+ + + +
+
+ `).join(''); + } catch (e) { + list.innerHTML = '

Error: ' + e.message + '

'; + } +} + +// Open ONLYOFFICE editor in a modal +async function openOnlyofficeEditor(templateId, templateName) { + try { + // Get the JWT-signed editor config from our backend + const resp = await fetch(ONLYOFFICE_API + '/' + templateId + '/config'); + 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; + + // Build the editor modal + const body = ` +
+

Editing: ${templateName}

+
+ + +
+
+
+

Changes are saved automatically. Click "Save" to force-save, or "Close Editor" to return to the template list.

+ `; + showModal(body, 'ONLYOFFICE Template Editor', 'large'); + + // Load ONLYOFFICE API script dynamically + const scriptUrl = onlyofficeUrl + '/web-apps/apps/api/documents/api.js'; + if (!document.getElementById('onlyoffice-api-script')) { + 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); + }); + } + + // 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); + } + + onlyofficeEditorInstance = new DocsAPI.DocEditor('onlyoffice-editor-container', { + ...config, + width: '100%', + height: '600px', + events: { + onAppReady: function() { + console.log('ONLYOFFICE editor ready'); + }, + onDocumentReady: function() { + console.log('Document loaded in editor'); + }, + onSave: function(event) { + toast('Document saved successfully'); + // Reload the templates list + loadOnlyofficeTemplates(); + }, + onError: function(event) { + console.error('ONLYOFFICE error:', event); + toast('Editor error: ' + (event.data?.errorDescription || 'unknown'), 'error'); + }, + }, + }); + } catch (e) { + toast('Failed to open editor: ' + e.message, 'error'); + } +} + +// 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 +function closeOnlyofficeEditor() { + if (onlyofficeEditorInstance) { + try { + onlyofficeEditorInstance.destroyEditor(); + } catch (e) { + console.error('Error closing editor:', e); + } + onlyofficeEditorInstance = null; + } + closeModal(); + // Refresh the templates list to show any changes + loadOnlyofficeTemplates(); +} + +// Call status check when templates page becomes active +document.addEventListener('DOMContentLoaded', () => { + const templatesPage = document.getElementById('page-templates'); + if (templatesPage) { + const observer = new MutationObserver(() => { + if (templatesPage.classList.contains('active')) { + checkOnlyofficeStatus(); + loadOnlyofficeTemplates(); + } + }); + observer.observe(templatesPage, { attributes: true, attributeFilter: ['class'] }); + } +}); \ No newline at end of file diff --git a/static/index.html b/static/index.html index ebcb80e..64c1b43 100644 --- a/static/index.html +++ b/static/index.html @@ -76,6 +76,16 @@ +
+
+ Inline Editor (ONLYOFFICE) + checking... +
+
+ Edit templates directly in your browser with ONLYOFFICE Document Server. No need to download/upload — just click "Edit" and save changes online. +
+
+
Upload Custom Template