commit 9e5b0b6f83b3024a8b86e32879f0121e4df8ea4f Author: Hannes Ahlin Date: Sat Aug 22 14:56:38 2026 +0200 Initial release v1.0.0 — Nextcloud Collabora Node-RED sidecar diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..11c88d1 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Nextcloud Collabora Node-RED sidecar — miljövariabler +# Kopiera till .env och fyll i app-lösenord i Node-RED UI (nc-collab-config) + +NEXTCLOUD_URL=https://cloud.hannesahlin.se +COLLABORA_URL=http://host.docker.internal:9980 +TZ=Europe/Stockholm + +# App-lösenord konfigureras i Node-RED (sparas krypterat i /data), +# inte här — undvik hemligheter i miljöfiler. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b5584a5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.sh text eol=lf +entrypoint.sh text eol=lf +install.sh text eol=lf +package.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5472e20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.env +/data/ +*.log +.DS_Store +Thumbs.db +*.tar.gz +!artifacts/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ef956e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM nodered/node-red:latest + +USER root + +COPY package.json /opt/nextcloud-collabora-nodered/package.json +COPY lib /opt/nextcloud-collabora-nodered/lib +COPY nodes /opt/nextcloud-collabora-nodered/nodes +COPY flows /opt/nextcloud-collabora-nodered/flows +COPY settings.js.example /opt/nextcloud-collabora-nodered/settings.js.example +COPY entrypoint.sh /opt/nextcloud-collabora-nodered/entrypoint.sh + +RUN chmod +x /opt/nextcloud-collabora-nodered/entrypoint.sh && \ + cd /opt/nextcloud-collabora-nodered && npm install --omit=dev + +USER node-red + +ENTRYPOINT ["/opt/nextcloud-collabora-nodered/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7885592 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# nextcloud-collabora-nodered + +Node-RED sidecar (port **1881**) för Nextcloud Office + Collabora CODE — WebDAV, richdocuments OCS och `/cool` REST. + +**Version 1.0 — 22 augusti 2026** + +## Snabbstart (Ubuntu) + +```bash +git clone /home/ncadmin/nextcloud-collabora-nodered +cd /home/ncadmin/nextcloud-collabora-nodered +cp .env.example .env +chmod +x install.sh +./install.sh +``` + +Öppna http://192.168.1.26:1881 → konfigurera `nc-collab-config` med **app-lösenord** → kör acceptansflödet. + +## Överföring från Windows + +```bash +# På Windows (Git Bash/WSL): +./package.sh + +# Kopiera till server: +scp artifacts/nextcloud-collabora-nodered-1.0.0.tar.gz ncadmin@192.168.1.26:/home/ncadmin/ + +# På Ubuntu: +mkdir -p ~/nextcloud-collabora-nodered +tar -xzf ~/nextcloud-collabora-nodered-1.0.0.tar.gz -C ~/nextcloud-collabora-nodered +cd ~/nextcloud-collabora-nodered && ./install.sh +``` + +## Noder + +| Nod | Syfte | +|-----|-------| +| `nc-collab-config` | Miljö + app-lösenord | +| `collabora-bridge` | Pipeline-exempel (börja här) | +| `nc-office` | richdocuments OCS | +| `collabora-convert` | `/cool/convert-to` (låg nivå) | +| `collabora-extract` | `/cool/extract-document-structure` | +| `collabora-transform` | `/cool/transform-document-structure` | +| `collabora-wopi` | discovery, capabilities, editor-URL | + +## Dokumentation + +- [docs/NODES.md](docs/NODES.md) — nodreferens och exempel +- `artifacts/Nextcloud-Collabora-noder.docx` — genereras med `./scripts/build-manual.sh` (kräver pandoc) + +## Gränser + +- Skriver till `/Dokument/OfficeOut/` som standard (original orört) +- Port 9980 exponeras **inte** — sidecar når CODE via `host.docker.internal` +- Befintlig `nextcloud-node-red:1880` lämnas orörd +- Odds-scraper ignoreras + +## Git + +```bash +git init # redan gjort vid leverans +git remote add origin +git push -u origin main +``` + +## Licens + +MIT diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/artifacts/.gitkeep b/artifacts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/README.md b/artifacts/README.md new file mode 100644 index 0000000..98a36af --- /dev/null +++ b/artifacts/README.md @@ -0,0 +1,6 @@ +# Docx-manual + +Kör `./scripts/build-manual.sh` (kräver pandoc) för att generera: + + +Källtext: [../docs/NODES.md](../docs/NODES.md) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8942b1a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + nextcloud-collabora-nodered: + build: . + image: nextcloud-collabora-nodered:latest + container_name: nextcloud-collabora-nodered + restart: unless-stopped + ports: + - "1881:1881" + environment: + PORT: "1881" + TZ: ${TZ:-Europe/Stockholm} + NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.hannesahlin.se} + COLLABORA_URL: ${COLLABORA_URL:-http://host.docker.internal:9980} + extra_hosts: + - "host.docker.internal:host-gateway" + - "cloud.hannesahlin.se:192.168.1.26" + volumes: + - nodered_collab_data:/data + +volumes: + nodered_collab_data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..b23e2e8 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +PKG_SRC="/opt/nextcloud-collabora-nodered" +PKG_DEST="/data/node_modules/node-red-contrib-nextcloud-collabora" + +rm -rf "$PKG_DEST" +cp -a "$PKG_SRC" "$PKG_DEST" + +if [ ! -f /data/settings.js ] && [ -f "$PKG_SRC/settings.js.example" ]; then + cp "$PKG_SRC/settings.js.example" /data/settings.js +fi + +if [ -f "$PKG_SRC/flows/example-flows.json" ] && [ ! -f /data/flows.json ]; then + cp "$PKG_SRC/flows/example-flows.json" /data/flows.json +fi + +exec npm --prefix /usr/src/node-red start -- --userDir /data --port "${PORT:-1881}" diff --git a/flows/example-flows.json b/flows/example-flows.json new file mode 100644 index 0000000..d148fcc --- /dev/null +++ b/flows/example-flows.json @@ -0,0 +1,108 @@ +[ + { + "id": "collab_tab", + "type": "tab", + "label": "NC Collabora — acceptans", + "disabled": false, + "info": "Version 1.0 — Hälsokoll + testkonvertering till /Dokument/OfficeOut/" + }, + { + "id": "nc_collab_cfg", + "type": "nc-collab-config", + "name": "cloud.hannesahlin.se", + "nextcloudUrl": "https://cloud.hannesahlin.se", + "collaboraUrl": "http://host.docker.internal:9980", + "collaboraPublicUrl": "", + "username": "FILL_IN_USER", + "verifyTls": false + }, + { + "id": "inj_capabilities", + "type": "inject", + "z": "collab_tab", + "name": "1. CODE capabilities", + "props": [], + "repeat": "", + "crontab": "", + "once": false, + "topic": "", + "x": 150, + "y": 80, + "wires": [["wopi_cap"]] + }, + { + "id": "wopi_cap", + "type": "collabora-wopi", + "z": "collab_tab", + "name": "capabilities", + "config": "nc_collab_cfg", + "operation": "capabilities", + "x": 380, + "y": 80, + "wires": [["dbg_cap"]] + }, + { + "id": "dbg_cap", + "type": "debug", + "z": "collab_tab", + "name": "capabilities JSON", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "payload", + "x": 590, + "y": 80, + "wires": [] + }, + { + "id": "inj_pdf", + "type": "inject", + "z": "collab_tab", + "name": "2. NC-fil → PDF", + "props": [ + { + "p": "ncPath", + "v": "/Dokument/test.docx", + "vt": "str" + }, + { + "p": "destNcPath", + "v": "/Dokument/OfficeOut/test.pdf", + "vt": "str" + } + ], + "repeat": "", + "crontab": "", + "once": false, + "topic": "", + "x": 150, + "y": 160, + "wires": [["bridge_pdf"]] + }, + { + "id": "bridge_pdf", + "type": "collabora-bridge", + "z": "collab_tab", + "name": "ncFileToPdf", + "config": "nc_collab_cfg", + "operation": "ncFileToPdf", + "x": 380, + "y": 160, + "wires": [["dbg_pdf"]] + }, + { + "id": "dbg_pdf", + "type": "debug", + "z": "collab_tab", + "name": "destNcPath (inga hemligheter)", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "destNcPath", + "x": 620, + "y": 160, + "wires": [] + } +] diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..0dedf22 --- /dev/null +++ b/install.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# install.sh — nextcloud-collabora-nodered v1.0 +# Kör på Ubuntu/Nextcloud-servern efter överföring från Windows. +set -euo pipefail + +VERSION="1.0.0" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_DIR="${INSTALL_DIR:-$SCRIPT_DIR}" +COMPOSE="docker compose" + +log() { printf '[install] %s\n' "$*"; } +die() { printf '[install] FEL: %s\n' "$*" >&2; exit 1; } + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Saknar kommando: $1" +} + +log "nextcloud-collabora-nodered v${VERSION}" +log "Installationskatalog: ${INSTALL_DIR}" + +require_cmd docker +if docker compose version >/dev/null 2>&1; then + COMPOSE="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE="docker-compose" +else + die "Docker Compose saknas (docker compose eller docker-compose)" +fi + +cd "${INSTALL_DIR}" + +if [ ! -f docker-compose.yml ]; then + die "docker-compose.yml saknas i ${INSTALL_DIR}" +fi + +if [ ! -f .env ]; then + log "Skapar .env från .env.example" + cp .env.example .env +fi + +# Valfri tarball-import (skickad från Windows) +if [ -n "${IMPORT_TARBALL:-}" ] && [ -f "${IMPORT_TARBALL}" ]; then + log "Extraherar ${IMPORT_TARBALL}" + tmpdir="$(mktemp -d)" + tar -xzf "${IMPORT_TARBALL}" -C "${tmpdir}" + src="$(find "${tmpdir}" -maxdepth 2 -name docker-compose.yml -printf '%h\n' | head -1)" + [ -n "${src}" ] || die "Ogiltig tarball — saknar docker-compose.yml" + rsync -a "${src}/" "${INSTALL_DIR}/" + rm -rf "${tmpdir}" +fi + +log "Bygger och startar container (port 1881)..." +${COMPOSE} build --no-cache +${COMPOSE} up -d + +log "Väntar på Node-RED..." +for i in $(seq 1 30); do + if curl -sf "http://127.0.0.1:1881" >/dev/null 2>&1; then + log "Node-RED svarar på http://127.0.0.1:1881" + break + fi + sleep 2 + if [ "$i" -eq 30 ]; then + log "Varning: Node-RED svarade inte inom 60 s — kontrollera: ${COMPOSE} logs -f" + fi +done + +cat <<'EOF' + +Nästa steg: +1. Öppna http://:1881 +2. Lägg till nc-collab-config med app-lösenord (inte kontolösen) +3. Importera/kör exempelflödet (flows/example-flows.json) +4. Testa: capabilities → ncFileToPdf mot känd fil → /Dokument/OfficeOut/ + +CODE ska redan köra på 127.0.0.1:9980 — starta inte ny CODE-container. +Befintlig nextcloud-node-red på :1880 lämnas orörd. + +EOF + +log "Klart." diff --git a/lib/bridge.js b/lib/bridge.js new file mode 100644 index 0000000..26d7df6 --- /dev/null +++ b/lib/bridge.js @@ -0,0 +1,258 @@ +'use strict'; + +const { webdavGet, webdavPut, webdavPropfindFileId } = require('./webdav'); +const { ocsRequest } = require('./ocs'); +const { convertTo, extractStructure, transformStructure } = require('./collabora'); +const { + resolveDestNcPath, + assertSafeDestination, + contentTypeForFormat, + replaceExtension, + basenameFromNcPath, + normalizeNcPath, + pickMsgField +} = require('./utils'); + +async function resolveFileId(config, msg) { + if (msg.fileId) { + return parseInt(msg.fileId, 10); + } + const ncPath = pickMsgField(msg, 'ncPath'); + if (ncPath) { + return webdavPropfindFileId(config, ncPath); + } + throw new Error('Ange msg.fileId eller msg.ncPath'); +} + +async function loadSourceBuffer(config, msg) { + if (msg.payload !== undefined && msg.payload !== null && msg.payload !== '') { + if (Buffer.isBuffer(msg.payload)) { + return { + buffer: msg.payload, + filename: msg.filename || 'payload.bin', + ncPath: msg.ncPath + }; + } + const text = typeof msg.payload === 'string' ? msg.payload : JSON.stringify(msg.payload); + return { + buffer: Buffer.from(text, 'utf8'), + filename: msg.filename || 'payload.txt', + ncPath: msg.ncPath + }; + } + + const ncPath = pickMsgField(msg, 'ncPath'); + if (!ncPath) { + throw new Error('Ange msg.ncPath eller msg.payload'); + } + const file = await webdavGet(config, ncPath); + return file; +} + +async function saveResult(config, msg, buffer, format, sourceNcPath) { + const destNcPath = assertSafeDestination( + resolveDestNcPath(msg, sourceNcPath, format), + msg + ); + const contentType = contentTypeForFormat(format); + const putResult = await webdavPut(config, destNcPath, buffer, contentType); + return { + destNcPath: putResult.destNcPath, + filename: putResult.filename, + contentType, + statusCode: putResult.statusCode + }; +} + +async function ncFileConvert(config, msg, format) { + const source = await loadSourceBuffer(config, msg); + const extraFields = []; + + if (msg.templateNcPath) { + const tpl = await webdavGet(config, msg.templateNcPath); + extraFields.push({ + name: 'template', + buffer: tpl.buffer, + filename: tpl.filename + }); + } + + if (msg.compareNcPath) { + const cmp = await webdavGet(config, msg.compareNcPath); + extraFields.push({ + name: 'compare', + buffer: cmp.buffer, + filename: cmp.filename + }); + } + + const converted = await convertTo(config, { + buffer: source.buffer, + filename: source.filename, + format, + extraFields + }); + + const saved = await saveResult(config, msg, converted.buffer, format, source.ncPath); + return { + ...saved, + format, + sourceNcPath: source.ncPath, + payload: converted.buffer + }; +} + +async function ncFileToPdf(config, msg) { + return ncFileConvert(config, msg, 'pdf'); +} + +async function ncFileToPng(config, msg) { + return ncFileConvert(config, msg, 'png'); +} + +async function ncFileToHtml(config, msg) { + return ncFileConvert(config, msg, 'html'); +} + +async function htmlToOffice(config, msg) { + const format = (pickMsgField(msg, 'format') || 'docx').toLowerCase(); + const source = await loadSourceBuffer(config, msg); + let buffer = source.buffer; + let filename = source.filename || 'content.html'; + + if (!filename.endsWith('.html') && !filename.endsWith('.htm')) { + filename = 'content.html'; + } + + const extraFields = []; + if (msg.templateNcPath) { + const tpl = await webdavGet(config, msg.templateNcPath); + extraFields.push({ + name: 'template', + buffer: tpl.buffer, + filename: tpl.filename + }); + } + + const converted = await convertTo(config, { + buffer, + filename, + format, + extraFields + }); + + const saved = await saveResult(config, msg, converted.buffer, format, msg.ncPath || filename); + return { + ...saved, + format, + payload: converted.buffer + }; +} + +async function fillTemplateSave(config, msg) { + const fileId = await resolveFileId(config, msg); + const fields = msg.fields || msg.transform; + if (!fields) { + throw new Error('Ange msg.fields eller msg.transform för mallifyllning'); + } + + const destination = msg.destNcPath + ? assertSafeDestination(normalizeNcPath(msg.destNcPath), msg) + : undefined; + + const body = { fields }; + if (destination) { + body.destination = destination.startsWith('/') ? destination.slice(1) : destination; + } + + await ocsRequest(config, { + method: 'POST', + path: `/ocs/v2.php/apps/richdocuments/api/v1/template/fields/fill/${fileId}`, + body + }); + + const destNcPath = destination || resolveDestNcPath(msg, msg.ncPath, 'docx'); + return { + fileId, + destNcPath: normalizeNcPath('/' + (body.destination || destNcPath.replace(/^\//, ''))), + statusCode: 200 + }; +} + +async function compareVersionsPdf(config, msg) { + if (!msg.compareNcPath) { + throw new Error('compareVersionsPdf kräver msg.compareNcPath (äldre version)'); + } + msg.format = 'pdf'; + return ncFileToPdf(config, msg); +} + +async function extractToPayload(config, msg) { + if (msg.fileId) { + const res = await ocsRequest(config, { + method: 'GET', + path: `/ocs/v2.php/apps/richdocuments/api/v1/template/fields/extract/${msg.fileId}` + }); + return { fields: res.data, fileId: msg.fileId }; + } + + const source = await loadSourceBuffer(config, msg); + const filter = msg.filter || 'contentcontrol'; + const structure = await extractStructure(config, { + buffer: source.buffer, + filename: source.filename, + filter + }); + return { + structure, + sourceNcPath: source.ncPath + }; +} + +async function createAssetLink(config, msg) { + const assetPath = pickMsgField(msg, 'ncPath'); + if (!assetPath) { + throw new Error('createAssetLink kräver msg.ncPath'); + } + + const relPath = normalizeNcPath(assetPath); + const { requestBuffer } = require('./http-client'); + const base = config.nextcloudUrl.replace(/\/$/, ''); + const token = Buffer.from(`${config.username}:${config.password}`).toString('base64'); + const res = await requestBuffer({ + url: `${base}/index.php/apps/richdocuments/assets`, + method: 'POST', + headers: { + Authorization: `Basic ${token}`, + 'OCS-APIRequest': 'true', + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: Buffer.from(JSON.stringify({ path: relPath }), 'utf8'), + rejectUnauthorized: config.verifyTls !== false && config.verifyTls !== 'false' + }); + + if (res.statusCode < 200 || res.statusCode >= 300) { + throw new Error(`assets:create misslyckades (${res.statusCode})`); + } + + const json = JSON.parse(res.body.toString('utf8')); + return { + assetUrl: json.url || json.assetUrl || json, + ncPath: relPath + }; +} + +module.exports = { + ncFileToPdf, + ncFileToPng, + ncFileToHtml, + htmlToOffice, + fillTemplateSave, + compareVersionsPdf, + extractToPayload, + createAssetLink, + loadSourceBuffer, + saveResult, + resolveFileId +}; diff --git a/lib/collabora.js b/lib/collabora.js new file mode 100644 index 0000000..7962317 --- /dev/null +++ b/lib/collabora.js @@ -0,0 +1,134 @@ +'use strict'; + +const FormData = require('form-data'); +const { requestStream } = require('./http-client'); + +function collaboraBase(config) { + return (config.collaboraUrl || '').replace(/\/$/, ''); +} + +function tlsOptions(config) { + return config.verifyTls !== false && config.verifyTls !== 'false'; +} + +function buildMultipart(fields) { + const form = new FormData(); + for (const field of fields) { + if (field.buffer) { + form.append(field.name, field.buffer, { + filename: field.filename || 'document.bin', + contentType: field.contentType || 'application/octet-stream' + }); + } else if (field.value !== undefined) { + form.append(field.name, String(field.value)); + } + } + return form; +} + +async function postMultipart(config, endpoint, fields) { + const form = buildMultipart(fields); + const url = `${collaboraBase(config)}${endpoint}`; + + const res = await requestStream( + { + url, + method: 'POST', + headers: form.getHeaders(), + rejectUnauthorized: tlsOptions(config) + }, + (req) => form.pipe(req) + ); + + if (res.statusCode < 200 || res.statusCode >= 300) { + const snippet = res.body.toString('utf8').slice(0, 300); + throw new Error(`Collabora ${endpoint} misslyckades (${res.statusCode}): ${snippet}`); + } + + if (!res.body || res.body.length === 0) { + throw new Error(`Collabora ${endpoint} returnerade tomt svar — kontrollera intern URL och post_allow`); + } + + return { + buffer: res.body, + contentType: res.headers['content-type'], + statusCode: res.statusCode + }; +} + +async function convertTo(config, { buffer, filename, format, extraFields = [] }) { + const fmt = String(format || 'pdf').toLowerCase(); + const fields = [ + { name: 'data', buffer, filename: filename || 'document.bin' }, + ...extraFields + ]; + + try { + return await postMultipart(config, `/cool/convert-to/${fmt}`, fields); + } catch (err) { + fields.push({ name: 'format', value: fmt }); + return postMultipart(config, '/cool/convert-to', fields); + } +} + +async function extractStructure(config, { buffer, filename, filter }) { + const fields = [{ name: 'data', buffer, filename: filename || 'document.bin' }]; + if (filter) { + fields.push({ name: 'filter', value: typeof filter === 'string' ? filter : JSON.stringify(filter) }); + } + const res = await postMultipart(config, '/cool/extract-document-structure', fields); + try { + return JSON.parse(res.buffer.toString('utf8')); + } catch { + return { raw: res.buffer.toString('utf8') }; + } +} + +async function transformStructure(config, { buffer, filename, transform }) { + const fields = [ + { name: 'data', buffer, filename: filename || 'document.bin' }, + { name: 'transform', value: typeof transform === 'string' ? transform : JSON.stringify(transform) } + ]; + const res = await postMultipart(config, '/cool/transform-document-structure', fields); + return { + buffer: res.buffer, + contentType: res.contentType + }; +} + +async function getCapabilities(config) { + const url = `${collaboraBase(config)}/hosting/capabilities`; + const { requestBuffer } = require('./http-client'); + const res = await requestBuffer({ + url, + method: 'GET', + rejectUnauthorized: tlsOptions(config) + }); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw new Error(`Capabilities misslyckades (${res.statusCode})`); + } + return JSON.parse(res.body.toString('utf8')); +} + +async function getDiscovery(config) { + const url = `${collaboraBase(config)}/hosting/discovery`; + const { requestBuffer } = require('./http-client'); + const res = await requestBuffer({ + url, + method: 'GET', + rejectUnauthorized: tlsOptions(config) + }); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw new Error(`Discovery misslyckades (${res.statusCode})`); + } + return res.body.toString('utf8'); +} + +module.exports = { + convertTo, + extractStructure, + transformStructure, + getCapabilities, + getDiscovery, + postMultipart +}; diff --git a/lib/http-client.js b/lib/http-client.js new file mode 100644 index 0000000..a93e968 --- /dev/null +++ b/lib/http-client.js @@ -0,0 +1,80 @@ +'use strict'; + +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); + +function requestBuffer(options) { + return new Promise((resolve, reject) => { + const url = new URL(options.url); + const transport = url.protocol === 'https:' ? https : http; + const reqOptions = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method: options.method || 'GET', + headers: options.headers || {}, + rejectUnauthorized: options.rejectUnauthorized !== false + }; + + const req = transport.request(reqOptions, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + body: Buffer.concat(chunks) + }); + }); + }); + + req.on('error', reject); + if (options.body) { + req.write(options.body); + } + req.end(); + }); +} + +function requestStream(options, writeBody) { + return new Promise((resolve, reject) => { + const url = new URL(options.url); + const transport = url.protocol === 'https:' ? https : http; + const reqOptions = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method: options.method || 'POST', + headers: options.headers || {}, + rejectUnauthorized: options.rejectUnauthorized !== false + }; + + const req = transport.request(reqOptions, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + headers: res.headers, + body: Buffer.concat(chunks) + }); + }); + }); + + req.on('error', reject); + if (writeBody) { + writeBody(req); + } else if (options.body) { + req.write(options.body); + req.end(); + } else { + req.end(); + } + }); +} + +module.exports = { + requestBuffer, + requestStream +}; diff --git a/lib/ocs.js b/lib/ocs.js new file mode 100644 index 0000000..b792f9e --- /dev/null +++ b/lib/ocs.js @@ -0,0 +1,89 @@ +'use strict'; + +const { requestBuffer } = require('./http-client'); + +function authHeader(config) { + const token = Buffer.from(`${config.username}:${config.password}`).toString('base64'); + return `Basic ${token}`; +} + +function ocsUrl(config, path, query) { + const base = config.nextcloudUrl.replace(/\/$/, ''); + const q = query || 'format=json'; + const sep = path.includes('?') ? '&' : '?'; + return `${base}${path}${sep}${q}`; +} + +function tlsOptions(config) { + return config.verifyTls !== false && config.verifyTls !== 'false'; +} + +function parseOcsBody(bodyBuffer) { + const text = bodyBuffer.toString('utf8'); + try { + const json = JSON.parse(text); + const ocs = json.ocs || json; + const meta = ocs.meta || {}; + const statuscode = meta.statuscode ?? meta.statusCode ?? 200; + if (statuscode >= 400) { + const message = meta.message || meta.status || text.slice(0, 200); + throw new Error(`OCS ${statuscode}: ${message}`); + } + return { + statusCode: statuscode, + data: ocs.data, + raw: json + }; + } catch (err) { + if (err.message.startsWith('OCS ')) { + throw err; + } + throw new Error(`OCS-svar kunde inte parsas: ${text.slice(0, 200)}`); + } +} + +async function ocsRequest(config, { method = 'GET', path, query, body, headers = {}, contentType }) { + const url = ocsUrl(config, path, query); + const reqHeaders = { + Authorization: authHeader(config), + 'OCS-APIRequest': 'true', + Accept: 'application/json', + ...headers + }; + + let payload; + if (body !== undefined) { + if (Buffer.isBuffer(body)) { + payload = body; + } else if (typeof body === 'string') { + payload = Buffer.from(body, 'utf8'); + reqHeaders['Content-Type'] = contentType || 'application/json'; + } else { + payload = Buffer.from(JSON.stringify(body), 'utf8'); + reqHeaders['Content-Type'] = contentType || 'application/json'; + } + reqHeaders['Content-Length'] = payload.length; + } + + const res = await requestBuffer({ + url, + method, + headers: reqHeaders, + body: payload, + rejectUnauthorized: tlsOptions(config) + }); + + if (res.statusCode === 401 || res.statusCode === 403) { + throw new Error(`OCS ${res.statusCode}: kontrollera app-lösenord`); + } + + return { + httpStatus: res.statusCode, + ...parseOcsBody(res.body) + }; +} + +module.exports = { + ocsRequest, + ocsUrl +}; diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..33ed7e8 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,138 @@ +'use strict'; + +const path = require('path'); + +const DEFAULT_OUTPUT_FOLDER = '/Dokument/OfficeOut/'; + +const MIME_BY_EXT = { + pdf: 'application/pdf', + png: 'image/png', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + odt: 'application/vnd.oasis.opendocument.text', + html: 'text/html', + htm: 'text/html', + txt: 'text/plain', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' +}; + +function normalizeNcPath(ncPath) { + if (!ncPath) { + return null; + } + let p = String(ncPath).trim(); + if (!p.startsWith('/')) { + p = '/' + p; + } + return p.replace(/\/+/g, '/'); +} + +function basenameFromNcPath(ncPath) { + const p = normalizeNcPath(ncPath); + if (!p) { + return 'output.bin'; + } + return path.posix.basename(p); +} + +function replaceExtension(filename, newExt) { + const base = filename.replace(/\.[^/.]+$/, ''); + const ext = newExt.startsWith('.') ? newExt.slice(1) : newExt; + return `${base}.${ext}`; +} + +function contentTypeForFormat(format) { + if (!format) { + return 'application/octet-stream'; + } + return MIME_BY_EXT[String(format).toLowerCase()] || 'application/octet-stream'; +} + +function defaultDestNcPath(sourceNcPath, format) { + const name = replaceExtension(basenameFromNcPath(sourceNcPath), format || 'bin'); + return normalizeNcPath(DEFAULT_OUTPUT_FOLDER + name); +} + +function resolveDestNcPath(msg, sourceNcPath, format) { + if (msg.destNcPath) { + return normalizeNcPath(msg.destNcPath); + } + if (msg.destFolder || msg.destName) { + const folder = normalizeNcPath(msg.destFolder || DEFAULT_OUTPUT_FOLDER); + const folderWithSlash = folder.endsWith('/') ? folder : folder + '/'; + const name = msg.destName || replaceExtension(basenameFromNcPath(sourceNcPath), format || 'bin'); + return normalizeNcPath(folderWithSlash + name); + } + return defaultDestNcPath(sourceNcPath, format); +} + +function assertSafeDestination(destNcPath, msg) { + const dest = normalizeNcPath(destNcPath); + const allowedPrefix = normalizeNcPath(DEFAULT_OUTPUT_FOLDER); + if (msg.allowOverwrite === true) { + return dest; + } + if (!dest.startsWith(allowedPrefix)) { + throw new Error( + `destNcPath "${dest}" ligger utanför ${allowedPrefix}. Sätt msg.allowOverwrite=true för att tillåta.` + ); + } + return dest; +} + +function getConfig(node) { + const cfg = node.configNode; + if (!cfg) { + throw new Error('Ingen nc-collab-config kopplad'); + } + return cfg; +} + +function filenameFromPath(ncPath) { + return basenameFromNcPath(ncPath); +} + +function pickMsgField(msg, ...keys) { + for (const key of keys) { + if (msg[key] !== undefined && msg[key] !== null && msg[key] !== '') { + return msg[key]; + } + } + return undefined; +} + +function setError(node, msg, err) { + const error = err instanceof Error ? err : new Error(String(err)); + msg.error = error.message; + msg.payload = { error: error.message }; + node.error(error.message, msg); + node.status({ fill: 'red', shape: 'ring', text: error.message.slice(0, 40) }); + return msg; +} + +function setSuccess(node, msg, text) { + node.status({ fill: 'green', shape: 'dot', text: text || 'ok' }); + return msg; +} + +function clearStatus(node) { + node.status({}); +} + +module.exports = { + DEFAULT_OUTPUT_FOLDER, + MIME_BY_EXT, + normalizeNcPath, + basenameFromNcPath, + replaceExtension, + contentTypeForFormat, + defaultDestNcPath, + resolveDestNcPath, + assertSafeDestination, + getConfig, + filenameFromPath, + pickMsgField, + setError, + setSuccess, + clearStatus +}; diff --git a/lib/webdav.js b/lib/webdav.js new file mode 100644 index 0000000..04d5369 --- /dev/null +++ b/lib/webdav.js @@ -0,0 +1,155 @@ +'use strict'; + +const { requestBuffer } = require('./http-client'); +const { normalizeNcPath, filenameFromPath } = require('./utils'); + +function authHeader(config) { + const token = Buffer.from(`${config.username}:${config.password}`).toString('base64'); + return `Basic ${token}`; +} + +function webDavBase(config) { + const base = config.nextcloudUrl.replace(/\/$/, ''); + return `${base}/remote.php/dav/files/${encodeURIComponent(config.username)}`; +} + +function encodePathSegments(ncPath) { + const normalized = normalizeNcPath(ncPath); + return normalized + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/'); +} + +function webDavUrl(config, ncPath) { + const rel = encodePathSegments(ncPath); + return rel ? `${webDavBase(config)}/${rel}` : webDavBase(config); +} + +function tlsOptions(config) { + return { rejectUnauthorized: config.verifyTls !== false && config.verifyTls !== 'false' }; +} + +async function webdavGet(config, ncPath) { + const url = webDavUrl(config, ncPath); + const res = await requestBuffer({ + url, + method: 'GET', + headers: { Authorization: authHeader(config) }, + rejectUnauthorized: tlsOptions(config).rejectUnauthorized + }); + + if (res.statusCode === 404) { + throw new Error(`WebDAV 404: filen finns inte: ${normalizeNcPath(ncPath)}`); + } + if (res.statusCode === 401 || res.statusCode === 403) { + throw new Error(`WebDAV ${res.statusCode}: kontrollera app-lösenord och användare`); + } + if (res.statusCode < 200 || res.statusCode >= 300) { + throw new Error(`WebDAV GET misslyckades (${res.statusCode}) för ${normalizeNcPath(ncPath)}`); + } + + return { + buffer: res.body, + filename: filenameFromPath(ncPath), + ncPath: normalizeNcPath(ncPath), + contentType: res.headers['content-type'] + }; +} + +async function ensureFolder(config, folderPath) { + const normalized = normalizeNcPath(folderPath); + if (!normalized || normalized === '/') { + return; + } + + const parts = normalized.split('/').filter(Boolean); + let current = ''; + for (const part of parts) { + current += '/' + part; + const url = webDavUrl(config, current); + const res = await requestBuffer({ + url, + method: 'MKCOL', + headers: { Authorization: authHeader(config) }, + rejectUnauthorized: tlsOptions(config).rejectUnauthorized + }); + if (res.statusCode !== 201 && res.statusCode !== 405 && res.statusCode !== 301) { + if (res.statusCode >= 400) { + throw new Error(`WebDAV MKCOL misslyckades (${res.statusCode}) för ${current}`); + } + } + } +} + +async function webdavPut(config, ncPath, buffer, contentType) { + const dest = normalizeNcPath(ncPath); + const folder = dest.substring(0, dest.lastIndexOf('/')) || '/'; + await ensureFolder(config, folder); + + const url = webDavUrl(config, dest); + const res = await requestBuffer({ + url, + method: 'PUT', + headers: { + Authorization: authHeader(config), + 'Content-Type': contentType || 'application/octet-stream', + 'Content-Length': buffer.length + }, + body: buffer, + rejectUnauthorized: tlsOptions(config).rejectUnauthorized + }); + + if (res.statusCode === 401 || res.statusCode === 403) { + throw new Error(`WebDAV ${res.statusCode}: kontrollera app-lösenord`); + } + if (res.statusCode < 200 || res.statusCode >= 300) { + throw new Error(`WebDAV PUT misslyckades (${res.statusCode}) för ${dest}`); + } + + return { + statusCode: res.statusCode, + destNcPath: dest, + filename: filenameFromPath(dest) + }; +} + +async function webdavPropfindFileId(config, ncPath) { + const url = webDavUrl(config, ncPath); + const body = ` + + +`; + + const res = await requestBuffer({ + url, + method: 'PROPFIND', + headers: { + Authorization: authHeader(config), + Depth: '0', + 'Content-Type': 'application/xml; charset=utf-8' + }, + body, + rejectUnauthorized: tlsOptions(config).rejectUnauthorized + }); + + if (res.statusCode < 200 || res.statusCode >= 300) { + throw new Error(`PROPFIND misslyckades (${res.statusCode}) för ${normalizeNcPath(ncPath)}`); + } + + const text = res.body.toString('utf8'); + const match = text.match(/(\d+)<\/oc:fileid>/i) || text.match(/(\d+)<\/nc:fileid>/i); + if (!match) { + throw new Error(`Kunde inte hitta fileId för ${normalizeNcPath(ncPath)}`); + } + return parseInt(match[1], 10); +} + +module.exports = { + webDavUrl, + webdavGet, + webdavPut, + webdavPropfindFileId, + ensureFolder +}; diff --git a/nodes/collabora-bridge.html b/nodes/collabora-bridge.html new file mode 100644 index 0000000..47d13f0 --- /dev/null +++ b/nodes/collabora-bridge.html @@ -0,0 +1,47 @@ + + + + + diff --git a/nodes/collabora-bridge.js b/nodes/collabora-bridge.js new file mode 100644 index 0000000..73732b4 --- /dev/null +++ b/nodes/collabora-bridge.js @@ -0,0 +1,64 @@ +'use strict'; + +const bridge = require('../lib/bridge'); +const { getConfig, setError, setSuccess, clearStatus, pickMsgField } = require('../lib/utils'); + +const OPERATIONS = { + ncFileToPdf: bridge.ncFileToPdf, + ncFileToPng: bridge.ncFileToPng, + ncFileToHtml: bridge.ncFileToHtml, + htmlToOffice: bridge.htmlToOffice, + fillTemplateSave: bridge.fillTemplateSave, + compareVersionsPdf: bridge.compareVersionsPdf, + extractToPayload: bridge.extractToPayload, + createAssetLink: bridge.createAssetLink +}; + +module.exports = function (RED) { + function CollaboraBridgeNode(config) { + RED.nodes.createNode(this, config); + const node = this; + node.configNode = RED.nodes.getNode(config.config); + node.operation = config.operation || 'ncFileToPdf'; + + node.on('input', async (msg, send, done) => { + send = send || function () { node.send.apply(node, arguments); }; + done = done || function (err) { if (err) { node.error(err, msg); } }; + clearStatus(node); + + try { + const cfg = getConfig(node); + const op = pickMsgField(msg, 'operation') || node.operation; + const fn = OPERATIONS[op]; + if (!fn) { + throw new Error(`Okänd bridge-operation: ${op}`); + } + + const result = await fn(cfg, msg); + Object.assign(msg, result); + if (result.payload !== undefined) { + msg.payload = result.payload; + } else { + msg.payload = { + destNcPath: result.destNcPath, + filename: result.filename, + format: result.format, + assetUrl: result.assetUrl, + fields: result.fields, + structure: result.structure + }; + } + + setSuccess(node, msg, op); + send(msg); + done(); + } catch (err) { + setError(node, msg, err); + send(msg); + done(err); + } + }); + } + + RED.nodes.registerType('collabora-bridge', CollaboraBridgeNode); +}; diff --git a/nodes/collabora-convert.html b/nodes/collabora-convert.html new file mode 100644 index 0000000..41e2b88 --- /dev/null +++ b/nodes/collabora-convert.html @@ -0,0 +1,48 @@ + + + + + diff --git a/nodes/collabora-convert.js b/nodes/collabora-convert.js new file mode 100644 index 0000000..0de467f --- /dev/null +++ b/nodes/collabora-convert.js @@ -0,0 +1,60 @@ +'use strict'; + +const { getConfig, setError, setSuccess, clearStatus, pickMsgField } = require('../lib/utils'); +const { convertTo } = require('../lib/collabora'); +const { loadSourceBuffer } = require('../lib/bridge'); +const { webdavGet } = require('../lib/webdav'); + +module.exports = function (RED) { + function CollaboraConvertNode(config) { + RED.nodes.createNode(this, config); + const node = this; + node.configNode = RED.nodes.getNode(config.config); + node.operation = config.operation || 'convert'; + node.format = config.format || 'pdf'; + + node.on('input', async (msg, send, done) => { + send = send || function () { node.send.apply(node, arguments); }; + done = done || function (err) { if (err) { node.error(err, msg); } }; + clearStatus(node); + + try { + const cfg = getConfig(node); + const format = (pickMsgField(msg, 'format') || node.format).toLowerCase(); + const source = await loadSourceBuffer(cfg, msg); + const extraFields = []; + + if (msg.templateNcPath) { + const tpl = await webdavGet(cfg, msg.templateNcPath); + extraFields.push({ name: 'template', buffer: tpl.buffer, filename: tpl.filename }); + } + if (msg.compareNcPath) { + const cmp = await webdavGet(cfg, msg.compareNcPath); + extraFields.push({ name: 'compare', buffer: cmp.buffer, filename: cmp.filename }); + } + + const result = await convertTo(cfg, { + buffer: source.buffer, + filename: source.filename, + format, + extraFields + }); + + msg.payload = result.buffer; + msg.format = format; + msg.contentType = result.contentType; + msg.statusCode = result.statusCode; + msg.filename = source.filename; + setSuccess(node, msg, `convert → ${format}`); + send(msg); + done(); + } catch (err) { + setError(node, msg, err); + send(msg); + done(err); + } + }); + } + + RED.nodes.registerType('collabora-convert', CollaboraConvertNode); +}; diff --git a/nodes/collabora-extract.html b/nodes/collabora-extract.html new file mode 100644 index 0000000..98c91ea --- /dev/null +++ b/nodes/collabora-extract.html @@ -0,0 +1,37 @@ + + + + + diff --git a/nodes/collabora-extract.js b/nodes/collabora-extract.js new file mode 100644 index 0000000..65528cc --- /dev/null +++ b/nodes/collabora-extract.js @@ -0,0 +1,43 @@ +'use strict'; + +const { getConfig, setError, setSuccess, clearStatus, pickMsgField } = require('../lib/utils'); +const { extractStructure } = require('../lib/collabora'); +const { loadSourceBuffer } = require('../lib/bridge'); + +module.exports = function (RED) { + function CollaboraExtractNode(config) { + RED.nodes.createNode(this, config); + const node = this; + node.configNode = RED.nodes.getNode(config.config); + node.filter = config.filter || 'contentcontrol'; + + node.on('input', async (msg, send, done) => { + send = send || function () { node.send.apply(node, arguments); }; + done = done || function (err) { if (err) { node.error(err, msg); } }; + clearStatus(node); + + try { + const cfg = getConfig(node); + const source = await loadSourceBuffer(cfg, msg); + const filter = pickMsgField(msg, 'filter') || node.filter; + const structure = await extractStructure(cfg, { + buffer: source.buffer, + filename: source.filename, + filter + }); + + msg.payload = structure; + msg.structure = structure; + setSuccess(node, msg, 'extract'); + send(msg); + done(); + } catch (err) { + setError(node, msg, err); + send(msg); + done(err); + } + }); + } + + RED.nodes.registerType('collabora-extract', CollaboraExtractNode); +}; diff --git a/nodes/collabora-transform.html b/nodes/collabora-transform.html new file mode 100644 index 0000000..c90c58c --- /dev/null +++ b/nodes/collabora-transform.html @@ -0,0 +1,32 @@ + + + + + diff --git a/nodes/collabora-transform.js b/nodes/collabora-transform.js new file mode 100644 index 0000000..3ff830c --- /dev/null +++ b/nodes/collabora-transform.js @@ -0,0 +1,46 @@ +'use strict'; + +const { getConfig, setError, setSuccess, clearStatus, pickMsgField } = require('../lib/utils'); +const { transformStructure } = require('../lib/collabora'); +const { loadSourceBuffer } = require('../lib/bridge'); + +module.exports = function (RED) { + function CollaboraTransformNode(config) { + RED.nodes.createNode(this, config); + const node = this; + node.configNode = RED.nodes.getNode(config.config); + + node.on('input', async (msg, send, done) => { + send = send || function () { node.send.apply(node, arguments); }; + done = done || function (err) { if (err) { node.error(err, msg); } }; + clearStatus(node); + + try { + const cfg = getConfig(node); + const transform = pickMsgField(msg, 'transform') || msg.fields; + if (!transform) { + throw new Error('Ange msg.transform eller msg.fields'); + } + + const source = await loadSourceBuffer(cfg, msg); + const result = await transformStructure(cfg, { + buffer: source.buffer, + filename: source.filename, + transform + }); + + msg.payload = result.buffer; + msg.contentType = result.contentType; + setSuccess(node, msg, 'transform'); + send(msg); + done(); + } catch (err) { + setError(node, msg, err); + send(msg); + done(err); + } + }); + } + + RED.nodes.registerType('collabora-transform', CollaboraTransformNode); +}; diff --git a/nodes/collabora-wopi.html b/nodes/collabora-wopi.html new file mode 100644 index 0000000..c6bcaee --- /dev/null +++ b/nodes/collabora-wopi.html @@ -0,0 +1,43 @@ + + + + + diff --git a/nodes/collabora-wopi.js b/nodes/collabora-wopi.js new file mode 100644 index 0000000..f9a77a8 --- /dev/null +++ b/nodes/collabora-wopi.js @@ -0,0 +1,69 @@ +'use strict'; + +const { getConfig, setError, setSuccess, clearStatus } = require('../lib/utils'); +const { getCapabilities, getDiscovery } = require('../lib/collabora'); +const { requestBuffer } = require('../lib/http-client'); + +module.exports = function (RED) { + function CollaboraWopiNode(config) { + RED.nodes.createNode(this, config); + const node = this; + node.configNode = RED.nodes.getNode(config.config); + node.operation = config.operation || 'capabilities'; + + node.on('input', async (msg, send, done) => { + send = send || function () { node.send.apply(node, arguments); }; + done = done || function (err) { if (err) { node.error(err, msg); } }; + clearStatus(node); + + try { + const cfg = getConfig(node); + const op = msg.operation || node.operation; + + if (op === 'capabilities') { + msg.payload = await getCapabilities(cfg); + } else if (op === 'discovery') { + msg.payload = await getDiscovery(cfg); + } else if (op === 'buildEditor') { + const publicBase = cfg.collaboraPublicUrl || cfg.collaboraUrl; + const wopiSrc = encodeURIComponent(msg.wopiSrc || msg.WOPISrc || ''); + const token = encodeURIComponent(msg.token || msg.access_token || ''); + if (!wopiSrc || !token) { + throw new Error('buildEditor kräver msg.wopiSrc och msg.token'); + } + msg.editorUrl = `${publicBase}/browser/dist/cool.html?WOPISrc=${wopiSrc}&access_token=${token}`; + msg.payload = { editorUrl: msg.editorUrl }; + } else if (op === 'checkFileInfo' || op === 'getFile') { + const wopiUrl = msg.wopiUrl; + if (!wopiUrl) { + throw new Error(`${op} kräver msg.wopiUrl (felsökning)`); + } + const token = msg.token || msg.access_token || ''; + const res = await requestBuffer({ + url: wopiUrl, + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'X-WOPI-Override': op === 'getFile' ? 'GET' : undefined + }, + rejectUnauthorized: cfg.verifyTls !== false + }); + msg.statusCode = res.statusCode; + msg.payload = op === 'getFile' ? res.body : JSON.parse(res.body.toString('utf8')); + } else { + throw new Error(`Okänd wopi-operation: ${op}`); + } + + setSuccess(node, msg, op); + send(msg); + done(); + } catch (err) { + setError(node, msg, err); + send(msg); + done(err); + } + }); + } + + RED.nodes.registerType('collabora-wopi', CollaboraWopiNode); +}; diff --git a/nodes/locales/en-US/nextcloud-collabora.json b/nodes/locales/en-US/nextcloud-collabora.json new file mode 100644 index 0000000..37b6ee3 --- /dev/null +++ b/nodes/locales/en-US/nextcloud-collabora.json @@ -0,0 +1,5 @@ +{ + "nextcloud-collabora": { + "nextcloud collabora": "Nextcloud Collabora" + } +} diff --git a/nodes/nc-collab-config.html b/nodes/nc-collab-config.html new file mode 100644 index 0000000..a11956f --- /dev/null +++ b/nodes/nc-collab-config.html @@ -0,0 +1,55 @@ + + + + + diff --git a/nodes/nc-collab-config.js b/nodes/nc-collab-config.js new file mode 100644 index 0000000..ba709b5 --- /dev/null +++ b/nodes/nc-collab-config.js @@ -0,0 +1,19 @@ +'use strict'; + +module.exports = function (RED) { + function NcCollabConfigNode(n) { + RED.nodes.createNode(this, n); + this.nextcloudUrl = (n.nextcloudUrl || '').replace(/\/$/, ''); + this.collaboraUrl = (n.collaboraUrl || '').replace(/\/$/, ''); + this.collaboraPublicUrl = (n.collaboraPublicUrl || '').replace(/\/$/, ''); + this.username = n.username || ''; + this.verifyTls = n.verifyTls !== false && n.verifyTls !== 'false'; + this.password = this.credentials.password || ''; + } + + RED.nodes.registerType('nc-collab-config', NcCollabConfigNode, { + credentials: { + password: { type: 'password' } + } + }); +}; diff --git a/nodes/nc-office.html b/nodes/nc-office.html new file mode 100644 index 0000000..1966ce7 --- /dev/null +++ b/nodes/nc-office.html @@ -0,0 +1,46 @@ + + + + + diff --git a/nodes/nc-office.js b/nodes/nc-office.js new file mode 100644 index 0000000..c508910 --- /dev/null +++ b/nodes/nc-office.js @@ -0,0 +1,146 @@ +'use strict'; + +const { getConfig, setError, setSuccess, clearStatus, pickMsgField } = require('../lib/utils'); +const { ocsRequest } = require('../lib/ocs'); +const bridge = require('../lib/bridge'); +const { resolveFileId } = bridge; +const { webdavGet, webdavPut } = require('../lib/webdav'); +const { convertTo } = require('../lib/collabora'); +const { + resolveDestNcPath, + assertSafeDestination, + contentTypeForFormat, + normalizeNcPath +} = require('../lib/utils'); + +module.exports = function (RED) { + function NcOfficeNode(config) { + RED.nodes.createNode(this, config); + const node = this; + node.configNode = RED.nodes.getNode(config.config); + node.operation = config.operation || 'templates:list'; + + node.on('input', async (msg, send, done) => { + send = send || function () { node.send.apply(node, arguments); }; + done = done || function (err) { if (err) { node.error(err, msg); } }; + clearStatus(node); + + try { + const cfg = getConfig(node); + const op = msg.operation || node.operation; + let result; + + switch (op) { + case 'templates:list': { + const type = pickMsgField(msg, 'templateType') || 'document'; + result = await ocsRequest(cfg, { + method: 'GET', + path: `/ocs/v2.php/apps/richdocuments/api/v1/templates/${type}` + }); + msg.payload = result.data; + break; + } + case 'templates:create': { + result = await ocsRequest(cfg, { + method: 'POST', + path: '/ocs/v2.php/apps/richdocuments/api/v1/templates/new', + body: { + templateId: msg.templateId, + fileName: msg.fileName || msg.destName, + templatePath: msg.templatePath + } + }); + msg.payload = result.data; + break; + } + case 'template:extractFields': { + const fileId = await resolveFileId(cfg, msg); + result = await ocsRequest(cfg, { + method: 'GET', + path: `/ocs/v2.php/apps/richdocuments/api/v1/template/fields/extract/${fileId}` + }); + msg.fileId = fileId; + msg.payload = result.data; + break; + } + case 'template:fillFields': { + const fileId = await resolveFileId(cfg, msg); + const fields = msg.fields || msg.transform; + const body = { fields }; + if (msg.destNcPath) { + body.destination = normalizeNcPath(msg.destNcPath).replace(/^\//, ''); + } + result = await ocsRequest(cfg, { + method: 'POST', + path: `/ocs/v2.php/apps/richdocuments/api/v1/template/fields/fill/${fileId}`, + body + }); + msg.fileId = fileId; + msg.payload = result.data; + break; + } + case 'document:direct': { + const fileId = await resolveFileId(cfg, msg); + result = await ocsRequest(cfg, { + method: 'POST', + path: '/ocs/v2.php/apps/richdocuments/api/v1/document', + body: { fileid: fileId } + }); + msg.fileId = fileId; + msg.editorUrl = result.data?.url || result.data; + msg.payload = { url: msg.editorUrl, fileId }; + break; + } + case 'document:public': { + if (!msg.shareToken) { + throw new Error('document:public kräver msg.shareToken'); + } + result = await ocsRequest(cfg, { + method: 'POST', + path: '/ocs/v2.php/apps/richdocuments/api/v1/document', + body: { shareToken: msg.shareToken } + }); + msg.editorUrl = result.data?.url || result.data; + msg.payload = { url: msg.editorUrl }; + break; + } + case 'files:convert': { + const format = (pickMsgField(msg, 'format') || 'pdf').toLowerCase(); + const source = msg.ncPath + ? await webdavGet(cfg, msg.ncPath) + : { buffer: Buffer.from(msg.payload), filename: msg.filename || 'file.bin' }; + const converted = await convertTo(cfg, { + buffer: source.buffer, + filename: source.filename, + format + }); + const dest = assertSafeDestination(resolveDestNcPath(msg, msg.ncPath, format), msg); + const put = await webdavPut(cfg, dest, converted.buffer, contentTypeForFormat(format)); + msg.destNcPath = put.destNcPath; + msg.payload = converted.buffer; + msg.format = format; + break; + } + case 'assets:create': { + const link = await bridge.createAssetLink(cfg, msg); + msg.assetUrl = link.assetUrl; + msg.payload = { assetUrl: link.assetUrl, ncPath: link.ncPath }; + break; + } + default: + throw new Error(`Okänd nc-office operation: ${op}`); + } + + setSuccess(node, msg, op); + send(msg); + done(); + } catch (err) { + setError(node, msg, err); + send(msg); + done(err); + } + }); + } + + RED.nodes.registerType('nc-office', NcOfficeNode); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..b2a1f2b --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "node-red-contrib-nextcloud-collabora", + "version": "1.0.0", + "description": "Node-RED nodes for Nextcloud Office + Collabora CODE (WebDAV, OCS richdocuments, /cool REST)", + "license": "MIT", + "author": "Hannes Ahlin", + "keywords": [ + "node-red", + "nextcloud", + "collabora", + "richdocuments", + "webdav", + "wopi" + ], + "node-red": { + "version": ">=3.0.0", + "nodes": { + "nc-collab-config": "nodes/nc-collab-config.js", + "collabora-convert": "nodes/collabora-convert.js", + "collabora-extract": "nodes/collabora-extract.js", + "collabora-transform": "nodes/collabora-transform.js", + "collabora-wopi": "nodes/collabora-wopi.js", + "nc-office": "nodes/nc-office.js", + "collabora-bridge": "nodes/collabora-bridge.js" + } + }, + "dependencies": { + "form-data": "^4.0.4" + }, + "engines": { + "node": ">=18" + } +} diff --git a/package.sh b/package.sh new file mode 100644 index 0000000..889c1a8 --- /dev/null +++ b/package.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# package.sh — skapa tarball för överföring Windows → Ubuntu +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +NAME="nextcloud-collabora-nodered-1.0.0" +OUT="${ROOT}/artifacts/${NAME}.tar.gz" + +mkdir -p "${ROOT}/artifacts" +tar -czf "${OUT}" \ + --exclude='.git' \ + --exclude='node_modules' \ + --exclude='artifacts/*.tar.gz' \ + --exclude='.env' \ + -C "${ROOT}" . + +echo "Skapad: ${OUT}" +echo "På Ubuntu: scp ${OUT} ncadmin@192.168.1.26:/home/ncadmin/" +echo " IMPORT_TARBALL=... ./install.sh" diff --git a/scripts/build-manual.sh b/scripts/build-manual.sh new file mode 100644 index 0000000..af1c97e --- /dev/null +++ b/scripts/build-manual.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Bygger artifacts/Nextcloud-Collabora-noder.docx från docs/NODES.md +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MD="${ROOT}/docs/NODES.md" +OUT="${ROOT}/artifacts/Nextcloud-Collabora-noder.docx" + +if ! command -v pandoc >/dev/null 2>&1; then + echo "pandoc saknas — installera med: sudo apt install pandoc" + echo "Markdown-källa finns i: ${MD}" + exit 1 +fi + +mkdir -p "${ROOT}/artifacts" +pandoc "${MD}" -o "${OUT}" +echo "Skapad: ${OUT}" diff --git a/settings.js.example b/settings.js.example new file mode 100644 index 0000000..0c8de33 --- /dev/null +++ b/settings.js.example @@ -0,0 +1,13 @@ +module.exports = { + uiPort: process.env.PORT || 1881, + credentialSecret: process.env.NODERED_CREDENTIAL_SECRET || "byt-den-här-hemligheten-vid-produktion", + flowFile: "flows.json", + userDir: "/data", + logging: { + console: { + level: "info", + metrics: false, + audit: false + } + } +};