Initial release v1.0.0 — Nextcloud Collabora Node-RED sidecar

This commit is contained in:
Hannes Ahlin
2026-08-22 14:56:38 +02:00
commit 9e5b0b6f83
37 changed files with 2035 additions and 0 deletions
+9
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
*.sh text eol=lf
entrypoint.sh text eol=lf
install.sh text eol=lf
package.sh text eol=lf
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.env
/data/
*.log
.DS_Store
Thumbs.db
*.tar.gz
!artifacts/.gitkeep
+17
View File
@@ -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"]
+68
View File
@@ -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 <din-remote> /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 <din-git-url>
git push -u origin main
```
## Licens
MIT
+1
View File
@@ -0,0 +1 @@
1.0.0
View File
+6
View File
@@ -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)
+21
View File
@@ -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:
+18
View File
@@ -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}"
+108
View File
@@ -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": []
}
]
+81
View File
@@ -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://<server-ip>: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."
+258
View File
@@ -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
};
+134
View File
@@ -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
};
+80
View File
@@ -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
};
+89
View File
@@ -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
};
+138
View File
@@ -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
};
+155
View File
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop><oc:fileid/></d:prop>
</d:propfind>`;
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(/<oc:fileid>(\d+)<\/oc:fileid>/i) || text.match(/<nc:fileid>(\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
};
+47
View File
@@ -0,0 +1,47 @@
<script type="text/javascript">
RED.nodes.registerType('collabora-bridge', {
category: 'nextcloud collabora',
color: '#e67e22',
defaults: {
name: { value: '' },
config: { type: 'nc-collab-config', required: true },
operation: { value: 'ncFileToPdf' }
},
inputs: 1,
outputs: 1,
icon: 'font-awesome/fa-exchange',
label: function () {
return this.name || this.operation;
}
});
</script>
<script type="text/html" data-template-name="collabora-bridge">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Namn</label>
<input type="text" id="node-input-name">
</div>
<div class="form-row">
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
<input type="text" id="node-input-config">
</div>
<div class="form-row">
<label for="node-input-operation"><i class="fa fa-wrench"></i> Pipeline</label>
<select id="node-input-operation">
<option value="ncFileToPdf">ncFileToPdf</option>
<option value="ncFileToPng">ncFileToPng</option>
<option value="ncFileToHtml">ncFileToHtml</option>
<option value="htmlToOffice">htmlToOffice</option>
<option value="fillTemplateSave">fillTemplateSave</option>
<option value="compareVersionsPdf">compareVersionsPdf</option>
<option value="extractToPayload">extractToPayload</option>
<option value="createAssetLink">createAssetLink</option>
</select>
</div>
</script>
<script type="text/html" data-help-name="collabora-bridge">
<p>Pipeline-exempel: NC-fil → konvertering → WebDAV PUT under <code>/Dokument/OfficeOut/</code>.</p>
<p>Skriver aldrig över original. Sätt <code>msg.allowOverwrite=true</code> för annan destNcPath.</p>
<p>Överstyr via <code>msg.operation</code>. Standardfält: <code>ncPath</code>, <code>destNcPath</code>, <code>format</code>, <code>fields</code>, <code>compareNcPath</code>.</p>
</script>
+64
View File
@@ -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);
};
+48
View File
@@ -0,0 +1,48 @@
<script type="text/javascript">
RED.nodes.registerType('collabora-convert', {
category: 'nextcloud collabora',
color: '#0082c9',
defaults: {
name: { value: '' },
config: { type: 'nc-collab-config', required: true },
operation: { value: 'convert' },
format: { value: 'pdf' }
},
inputs: 1,
outputs: 1,
icon: 'font-awesome/fa-file-pdf-o',
label: function () {
return this.name || `convert → ${this.format}`;
}
});
</script>
<script type="text/html" data-template-name="collabora-convert">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Namn</label>
<input type="text" id="node-input-name">
</div>
<div class="form-row">
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
<input type="text" id="node-input-config">
</div>
<div class="form-row">
<label for="node-input-format"><i class="fa fa-file"></i> Format</label>
<select id="node-input-format">
<option value="pdf">pdf</option>
<option value="png">png</option>
<option value="docx">docx</option>
<option value="odt">odt</option>
<option value="html">html</option>
<option value="txt">txt</option>
<option value="xlsx">xlsx</option>
<option value="pptx">pptx</option>
</select>
</div>
</script>
<script type="text/html" data-help-name="collabora-convert">
<p>Låg nivå: <code>/cool/convert-to</code> mot CODE.</p>
<p>Ingång: <code>msg.ncPath</code> eller <code>msg.payload</code> (Buffer). Valfritt <code>msg.format</code>, <code>msg.templateNcPath</code>, <code>msg.compareNcPath</code>.</p>
<p>Utdata: <code>msg.payload</code> (Buffer). Sparar inte till Nextcloud — använd bridge eller WebDAV PUT efteråt.</p>
</script>
+60
View File
@@ -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);
};
+37
View File
@@ -0,0 +1,37 @@
<script type="text/javascript">
RED.nodes.registerType('collabora-extract', {
category: 'nextcloud collabora',
color: '#0082c9',
defaults: {
name: { value: '' },
config: { type: 'nc-collab-config', required: true },
filter: { value: 'contentcontrol' }
},
inputs: 1,
outputs: 1,
icon: 'font-awesome/fa-sitemap',
label: function () {
return this.name || 'extract structure';
}
});
</script>
<script type="text/html" data-template-name="collabora-extract">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Namn</label>
<input type="text" id="node-input-name">
</div>
<div class="form-row">
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
<input type="text" id="node-input-config">
</div>
<div class="form-row">
<label for="node-input-filter"><i class="fa fa-filter"></i> Filter</label>
<input type="text" id="node-input-filter" placeholder="contentcontrol">
</div>
</script>
<script type="text/html" data-help-name="collabora-extract">
<p><code>/cool/extract-document-structure</code></p>
<p>Ingång: <code>msg.ncPath</code> eller Buffer i <code>msg.payload</code>. Valfritt <code>msg.filter</code>.</p>
</script>
+43
View File
@@ -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);
};
+32
View File
@@ -0,0 +1,32 @@
<script type="text/javascript">
RED.nodes.registerType('collabora-transform', {
category: 'nextcloud collabora',
color: '#0082c9',
defaults: {
name: { value: '' },
config: { type: 'nc-collab-config', required: true }
},
inputs: 1,
outputs: 1,
icon: 'font-awesome/fa-magic',
label: function () {
return this.name || 'transform structure';
}
});
</script>
<script type="text/html" data-template-name="collabora-transform">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Namn</label>
<input type="text" id="node-input-name">
</div>
<div class="form-row">
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
<input type="text" id="node-input-config">
</div>
</script>
<script type="text/html" data-help-name="collabora-transform">
<p><code>/cool/transform-document-structure</code></p>
<p>Ingång: källa via <code>msg.ncPath</code> eller <code>msg.payload</code>, plus <code>msg.transform</code> (JSON).</p>
</script>
+46
View File
@@ -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);
};
+43
View File
@@ -0,0 +1,43 @@
<script type="text/javascript">
RED.nodes.registerType('collabora-wopi', {
category: 'nextcloud collabora',
color: '#0082c9',
defaults: {
name: { value: '' },
config: { type: 'nc-collab-config', required: true },
operation: { value: 'capabilities' }
},
inputs: 1,
outputs: 1,
icon: 'font-awesome/fa-search',
label: function () {
return this.name || `wopi ${this.operation}`;
}
});
</script>
<script type="text/html" data-template-name="collabora-wopi">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Namn</label>
<input type="text" id="node-input-name">
</div>
<div class="form-row">
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
<input type="text" id="node-input-config">
</div>
<div class="form-row">
<label for="node-input-operation"><i class="fa fa-wrench"></i> Operation</label>
<select id="node-input-operation">
<option value="capabilities">capabilities</option>
<option value="discovery">discovery</option>
<option value="buildEditor">buildEditor</option>
<option value="checkFileInfo">checkFileInfo (debug)</option>
<option value="getFile">getFile (debug)</option>
</select>
</div>
</script>
<script type="text/html" data-help-name="collabora-wopi">
<p>WOPI discovery/capabilities och editor-URL. Använd WebDAV PUT för filskrivning.</p>
<p><code>buildEditor</code>: <code>msg.wopiSrc</code> + <code>msg.token</code> → <code>msg.editorUrl</code>.</p>
</script>
+69
View File
@@ -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);
};
@@ -0,0 +1,5 @@
{
"nextcloud-collabora": {
"nextcloud collabora": "Nextcloud Collabora"
}
}
+55
View File
@@ -0,0 +1,55 @@
<script type="text/javascript">
RED.nodes.registerType('nc-collab-config', {
category: 'config',
defaults: {
name: { value: '' },
nextcloudUrl: { value: 'https://cloud.hannesahlin.se', required: true },
collaboraUrl: { value: 'http://host.docker.internal:9980', required: true },
collaboraPublicUrl: { value: '' },
username: { value: '', required: true },
verifyTls: { value: false }
},
credentials: {
password: { type: 'password' }
},
label: function () {
return this.name || `NC Collab (${this.username || '?'})`;
}
});
</script>
<script type="text/html" data-template-name="nc-collab-config">
<div class="form-row">
<label for="node-config-input-name"><i class="fa fa-tag"></i> Namn</label>
<input type="text" id="node-config-input-name" placeholder="t.ex. cloud.hannesahlin.se">
</div>
<div class="form-row">
<label for="node-config-input-nextcloudUrl"><i class="fa fa-cloud"></i> Nextcloud URL</label>
<input type="text" id="node-config-input-nextcloudUrl">
</div>
<div class="form-row">
<label for="node-config-input-collaboraUrl"><i class="fa fa-server"></i> Collabora intern URL</label>
<input type="text" id="node-config-input-collaboraUrl">
</div>
<div class="form-row">
<label for="node-config-input-collaboraPublicUrl"><i class="fa fa-globe"></i> Collabora publik URL (valfri)</label>
<input type="text" id="node-config-input-collaboraPublicUrl">
</div>
<div class="form-row">
<label for="node-config-input-username"><i class="fa fa-user"></i> Användare</label>
<input type="text" id="node-config-input-username">
</div>
<div class="form-row">
<label for="node-config-input-password"><i class="fa fa-key"></i> App-lösenord</label>
<input type="password" id="node-config-input-password">
</div>
<div class="form-row">
<label for="node-config-input-verifyTls"><i class="fa fa-lock"></i> Verifiera TLS</label>
<input type="checkbox" id="node-config-input-verifyTls" style="width:auto">
</div>
</script>
<script type="text/html" data-help-name="nc-collab-config">
<p>En instans per miljö. Använd <strong>app-lösenord</strong>, aldrig vanligt kontolösen.</p>
<p>Fält: <code>nextcloudUrl</code>, <code>collaboraUrl</code>, <code>username</code>, app-lösen, <code>verifyTls</code>.</p>
</script>
+19
View File
@@ -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' }
}
});
};
+46
View File
@@ -0,0 +1,46 @@
<script type="text/javascript">
RED.nodes.registerType('nc-office', {
category: 'nextcloud collabora',
color: '#46ba61',
defaults: {
name: { value: '' },
config: { type: 'nc-collab-config', required: true },
operation: { value: 'templates:list' }
},
inputs: 1,
outputs: 1,
icon: 'font-awesome/fa-file-text-o',
label: function () {
return this.name || `nc-office ${this.operation}`;
}
});
</script>
<script type="text/html" data-template-name="nc-office">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Namn</label>
<input type="text" id="node-input-name">
</div>
<div class="form-row">
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
<input type="text" id="node-input-config">
</div>
<div class="form-row">
<label for="node-input-operation"><i class="fa fa-wrench"></i> Operation</label>
<select id="node-input-operation">
<option value="templates:list">templates:list</option>
<option value="templates:create">templates:create</option>
<option value="template:extractFields">template:extractFields</option>
<option value="template:fillFields">template:fillFields</option>
<option value="document:direct">document:direct</option>
<option value="document:public">document:public</option>
<option value="files:convert">files:convert</option>
<option value="assets:create">assets:create</option>
</select>
</div>
</script>
<script type="text/html" data-help-name="nc-office">
<p>Richdocuments OCS — mallar, fält, direkt-URL, assets.</p>
<p>Överstyr via <code>msg.operation</code>. Gemensamma fält: <code>ncPath</code>, <code>fileId</code>, <code>destNcPath</code>, <code>fields</code>.</p>
</script>
+146
View File
@@ -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);
};
+33
View File
@@ -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"
}
}
+18
View File
@@ -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"
+16
View File
@@ -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}"
+13
View File
@@ -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
}
}
};