commit fd7cc695f72e18e30ed77090145d36515521a8fb Author: newkle3r Date: Fri May 15 14:50:48 2026 +0200 Initial commit: Nextcloud Node-RED Docker image and custom nodes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8900ccc --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Node-RED runtime (local dev) +.flow.json +.flow_cred.json +.node-red/ + +# Dependencies (if installed locally for testing) +node_modules/ +nodes/nextcloud-ocs/node_modules/ + +# OS / editor +.DS_Store +Thumbs.db +.vscode/ +*.swp +*.swo + +# Secrets +.env +.env.* +*.pem +credentials.json + +# Logs +*.log +npm-debug.log* + +# Docker local overrides +docker-compose.override.yml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8b37cc1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to **node-red-contrib-nextcloud-ocs** and the Docker image are documented here. + +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [0.8.0] - Unreleased + +### Added + +- Nodes: `core`, `oauth2`, `provisioning`, `filesharing`, `userstatus`, `settings` +- Nodes: `webhooks`, `dashboard`, `dav` (from 0.6.x line) +- Docker `ENTRYPOINT` runs `entrypoint.sh` (fixes nodes not copied to `/data`) +- Entrypoint logging and install verification +- Documentation: README, ARCHITECTURE, DEPLOYMENT, NODES, TROUBLESHOOTING + +### Fixed + +- Node-RED `userDir` forced to `/data` so palette loads from volume `node_modules` +- Removed brittle copy of default `settings.js` (path differs in base image) +- Stale `node-red-data` volume keeping old package versions (documented) + +## [0.6.0] + +### Added + +- `webhooks`, `dashboard`, `dav` nodes +- 10-node package (config + 9 functional nodes) + +## [0.5.0] + +### Added + +- Initial set: `nextcloud-config`, `ocs-api`, `collectives`, `file-operations`, `mail`, `tables`, `talk` + +[0.8.0]: https://github.com/your-org/nextcloud-node-red/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/your-org/nextcloud-node-red/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/your-org/nextcloud-node-red/releases/tag/v0.5.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..37cb321 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM nodered/node-red:latest + +USER root + +COPY nodes/nextcloud-ocs /opt/nextcloud-nodes/node-red-contrib-nextcloud-ocs +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1168e7b --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + See https://www.gnu.org/licenses/agpl-3.0.txt for the full license text. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . diff --git a/README.md b/README.md new file mode 100644 index 0000000..17a93d9 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# Nextcloud Node-RED Integration + +Custom [Node-RED](https://nodered.org/) Docker image with **16 palette nodes** that call Nextcloud APIs (OCS, WebDAV, and app-specific endpoints), plus a companion [Nextcloud app](../nodered-embed/) that embeds the editor in the Nextcloud UI. + +## Repositories + +| Directory | Purpose | +|-----------|---------| +| **`nextcloud-node-red/`** (this repo) | Docker image + `node-red-contrib-nextcloud-ocs` custom nodes | +| **`nodered-embed/`** | Nextcloud PHP app — iframe embed, CSP, admin settings | + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Nextcloud (https://your-cloud.example) │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ nodered_embed app → iframe → Node-RED :1880 │ │ +│ │ CSPListener allows frame-src / frame-ancestors │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└───────────────────────────────┬─────────────────────────────────┘ + │ Basic Auth (app password) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Docker: nextcloud-node-red │ +│ userDir: /data → /data/node_modules/node-red-contrib-... │ +│ entrypoint copies nodes from /opt at container start │ +└─────────────────────────────────────────────────────────────────┘ +``` + +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for implementation details. + +## Requirements + +- Linux host (or VM) with Docker +- Nextcloud 25+ (tested with modern OCS v2 APIs) +- **`network_mode: host`** on Linux so the container can reach Nextcloud on the same machine (avoids `127.0.0.1` / `/etc/hosts` issues inside the container) +- Nextcloud **app password** per user (Settings → Security → Devices & sessions) + +## Quick start + +### Docker Compose (recommended) + +```bash +git clone nextcloud-node-red +cd nextcloud-node-red +docker compose up -d --build +``` + +Open **http://localhost:1880** (or the host IP if remote). + +### Manual Docker + +```bash +docker build -t nextcloud-node-red:latest . +docker run -d \ + --name nextcloud-node-red \ + --network host \ + -v node-red-data:/data \ + --restart unless-stopped \ + nextcloud-node-red:latest +``` + +> **Important:** Use `--network host` on Linux. Without it, `https://localhost` or your server hostname may resolve incorrectly inside the container. + +### Nextcloud embed app + +Copy `nodered-embed/` to your Nextcloud apps directory and enable it: + +```bash +cp -r nodered-embed /var/www/nextcloud/apps/ +sudo -u www-data php /var/www/nextcloud/occ app:enable nodered_embed +``` + +In **Administration → Node-RED Embed**, set the Node-RED URL (e.g. `http://192.168.1.26:1880`). See [nodered-embed README](../nodered-embed/README.md). + +## First-time setup in Node-RED + +1. Drag a **nextcloud config** node onto the canvas (any Nextcloud node will prompt you). +2. Set **Nextcloud URL** to your instance, e.g. `https://cloud.example.com` (no trailing slash). +3. Set **Username** and **App password** (not your login password). +4. Deploy and test with **ocs-api** → operation `GET` on `/ocs/v2.php/cloud/user`. + +## Custom nodes (overview) + +| Node | Palette category | Description | +|------|------------------|-------------| +| `nextcloud-config` | config | Shared credentials (URL, user, app password) | +| `ocs-api` | nextcloud | Generic OCS HTTP (any path/method) | +| `collectives` | nextcloud | Collectives app (~80 operations) | +| `file-operations` | nextcloud | WebDAV files + OCS sharing | +| `mail` | nextcloud | Mail app | +| `tables` | nextcloud | Tables app | +| `talk` | nextcloud | Talk (Spreed) | +| `webhooks` | nextcloud | Webhook Listeners | +| `dashboard` | nextcloud | Dashboard widgets | +| `dav` | nextcloud | DAV (direct links, out-of-office) | +| `core` | nextcloud | Core OCS (capabilities, search, tasks, …) | +| `oauth2` | nextcloud | OAuth2 clients | +| `provisioning` | nextcloud | User/group provisioning API | +| `filesharing` | nextcloud | File sharing OCS | +| `userstatus` | nextcloud | User status | +| `settings` | nextcloud | User/admin settings | + +Full operation lists: [docs/NODES.md](docs/NODES.md). + +### Runtime overrides + +Most nodes accept `msg` overrides: + +| Property | Effect | +|----------|--------| +| `msg.operation` | Operation id (e.g. `collective:list`) | +| `msg.endpoint` | **ocs-api only** — API path | +| `msg.method` | **ocs-api only** — HTTP method | +| `msg.body` | JSON request body | +| `msg.headers` | Extra HTTP headers | +| Path params | `msg.collectiveId`, `msg.pageId`, `msg.userId`, etc. | + +## Updating nodes after code changes + +The image bakes nodes under `/opt/nextcloud-nodes/`. At **every container start**, `entrypoint.sh` copies them into the **`/data` volume** (Node-RED `userDir`). To pick up new node files: + +```bash +docker build --no-cache -t nextcloud-node-red:latest . +docker rm -f nextcloud-node-red +docker run -d --name nextcloud-node-red --network host \ + -v node-red-data:/data --restart unless-stopped nextcloud-node-red:latest +``` + +If the palette still shows an old version, remove the volume ( **deletes flows** ): + +```bash +docker rm -f nextcloud-node-red +docker volume rm node-red-data +docker run -d ... # same run command as above +``` + +See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). + +## Project layout + +``` +nextcloud-node-red/ +├── Dockerfile # Base: nodered/node-red, COPY nodes + entrypoint +├── entrypoint.sh # Install nodes into /data, start node-red +├── docker-compose.yml # host network + named volume +├── nodes/nextcloud-ocs/ # node-red-contrib-nextcloud-ocs package +│ ├── package.json # node-red.nodes registry (16 nodes) +│ ├── nextcloud-config.js / .html +│ ├── ocs-api.js / .html +│ └── … # one .js + .html pair per node +└── docs/ + ├── ARCHITECTURE.md + ├── DEPLOYMENT.md + ├── NODES.md + └── TROUBLESHOOTING.md +``` + +## Security notes + +- Credentials are stored in Node-RED’s encrypted credentials store; set `credentialSecret` in `/data/settings.js` for production. +- Nodes use **Basic Auth** with app passwords; scope passwords per automation. +- `rejectUnauthorized: false` is used for HTTPS to allow self-signed certs — tighten for production if you use proper TLS. +- The Nextcloud embed iframe uses a restrictive `sandbox` attribute; adjust only if you need additional browser capabilities. + +## Documentation + +- [Architecture](docs/ARCHITECTURE.md) +- [Deployment guide](docs/DEPLOYMENT.md) +- [Node reference](docs/NODES.md) +- [Troubleshooting](docs/TROUBLESHOOTING.md) +- [Nextcloud embed app](../nodered-embed/README.md) + +## License + +AGPL-3.0 — see [LICENSE](LICENSE). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..484e494 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + node-red: + build: . + image: nextcloud-node-red:latest + container_name: nextcloud-node-red + restart: unless-stopped + network_mode: host + volumes: + - node-red-data:/data + +volumes: + node-red-data: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6c3e6d4 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,81 @@ +# Architecture + +## Two components + +### 1. `nextcloud-node-red` (Docker + nodes) + +A thin wrapper around the official [`nodered/node-red`](https://hub.docker.com/r/nodered/node-red) image: + +1. **Build time:** `nodes/nextcloud-ocs/` is copied to `/opt/nextcloud-nodes/node-red-contrib-nextcloud-ocs`. +2. **Run time:** `entrypoint.sh` runs as container `ENTRYPOINT`, copies that tree into `/data/node_modules/node-red-contrib-nextcloud-ocs`, then starts `node-red --userDir /data`. + +Node-RED discovers contrib nodes from `{userDir}/node_modules/*/package.json` → `node-red.nodes` map. + +### 2. `nodered-embed` (Nextcloud app) + +PHP app registered as `nodered_embed`: + +- **Navigation** entry opens a full-page iframe to the configured Node-RED URL. +- **CSPListener** relaxes Content-Security-Policy so Nextcloud may embed Node-RED (`frame-src`) and Node-RED may embed Nextcloud pages if needed (`frame-ancestors`). +- **Admin settings** store `nodered_url`, optional Docker container name, and a Docker-management flag (UI only for now). + +## Node implementation pattern + +Each functional node follows the same structure: + +``` +collectives.js → OPERATIONS hash (operationId → method, path, body fields) +collectives.html → Editor UI, dropdown of operations, config node picker +nextcloud-config.js → Credential node (baseUrl, username, password) +``` + +On input: + +1. Resolve `msg.operation` or the configured default operation. +2. Substitute path placeholders (`{id}`, `{collectiveId}`, …) from config + `msg.*`. +3. Build query string and JSON body from configured fields + `msg` overrides. +4. HTTP request with `OCS-APIRequest: true`, `Accept: application/json`, Basic Auth. +5. Parse JSON into `msg.payload`, set `msg.statusCode`, `node.send(msg)`. + +**ocs-api** is the escape hatch: you supply any OCS path and method without adding a new operation to the hash. + +## Data persistence + +| Path | Contents | +|------|----------| +| `/data/flows.json` | Flows (Docker volume `node-red-data`) | +| `/data/flows_cred.json` | Encrypted credentials | +| `/data/node_modules/node-red-contrib-nextcloud-ocs/` | Copy of custom nodes (refreshed on each start) | + +The named volume **masks** anything copied into `/data` at image build time. That is why the entrypoint always re-copies from `/opt`. + +## Network + +``` +Node-RED container (--network host) + → https://cloud.example.com (same host or LAN) +``` + +On Ubuntu, a bridge network often breaks `localhost` / hostname resolution (`127.0.1.1` in `/etc/hosts`). Host networking avoids that for co-located Nextcloud + Node-RED. + +## Authentication flow + +``` +Editor: nextcloud-config node + → stored in flows_cred.json (encrypted) + +Runtime: each API node + → RED.nodes.getNode(config.nextcloud) + → credentials.username + credentials.password + → Authorization: Basic base64(user:app-password) +``` + +Use **app passwords**, not the account login password. + +## Versioning + +Package version in `nodes/nextcloud-ocs/package.json` (e.g. `0.8.0`) should be bumped when adding nodes or breaking operation ids. After deploy, verify inside the container: + +```bash +docker exec nextcloud-node-red cat /data/node_modules/node-red-contrib-nextcloud-ocs/package.json +``` diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..448abaf --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,138 @@ +# Deployment guide + +## Target layout (example) + +| Service | Host | Port | +|---------|------|------| +| Nextcloud | `https://cloud.example.com` | 443 | +| Node-RED | same VM, `192.168.1.26` | 1880 | + +Node-RED must be reachable from users’ browsers (for the iframe) and from itself (for API calls to Nextcloud). + +## 1. Deploy Node-RED (Docker) + +On the Ubuntu VM: + +```bash +cd /home/ncadmin/nextcloud-node-red # or your clone path +git pull # get latest nodes + entrypoint + +docker compose build --no-cache +docker compose up -d +``` + +Or without Compose: + +```bash +docker build --no-cache -t nextcloud-node-red:latest . +docker rm -f nextcloud-node-red 2>/dev/null || true +docker run -d \ + --name nextcloud-node-red \ + --network host \ + -v node-red-data:/data \ + --restart unless-stopped \ + nextcloud-node-red:latest +``` + +Verify: + +```bash +docker logs -n 30 nextcloud-node-red +# Expect: [entrypoint] Installing nextcloud nodes... +# User directory : /data + +curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:1880/ +# Expect: 200 +``` + +### Nextcloud URL in config nodes + +Inside Node-RED, set **Nextcloud URL** to something the **container** can resolve: + +| Scenario | URL | +|----------|-----| +| Nextcloud on same VM, host networking | `https://127.0.0.1` or `https://cloud.example.com` | +| Nextcloud on another host | `https://192.168.1.x` or public hostname | + +Test from inside the container: + +```bash +docker exec nextcloud-node-red wget -qO- --no-check-certificate \ + https://cloud.example.com/status.php +``` + +## 2. Deploy Nextcloud app + +```bash +sudo cp -r nodered-embed /var/www/nextcloud/apps/ +sudo chown -R www-data:www-data /var/www/nextcloud/apps/nodered-embed +sudo -u www-data php /var/www/nextcloud/occ app:enable nodered_embed +``` + +Configure in **Settings → Administration → Node-RED Embed**: + +- **Node-RED URL:** `http://192.168.1.26:1880` (use the address clients use; not `127.0.0.1` unless only local admins use it) + +## 3. Reverse proxy (optional) + +If Node-RED is behind nginx/Apache with TLS: + +- Ensure the proxy does **not** send `X-Frame-Options: DENY` (blocks iframe embed). +- Point Nextcloud admin URL to the public HTTPS URL, e.g. `https://nodered.example.com`. + +If Nextcloud is behind a proxy, CSP host extraction uses the hostname from `nodered_url` — use the same hostname users load in the iframe. + +## 4. Firewall + +Allow **1880/tcp** (or your mapped port) from Nextcloud users’ networks if they open the embed from LAN/VPN. + +## 5. Updating custom nodes + +After changing files under `nodes/nextcloud-ocs/`: + +```bash +docker builder prune -af # optional, avoids stale COPY cache +docker build --no-cache -t nextcloud-node-red:latest . +docker restart nextcloud-node-red +``` + +Confirm version: + +```bash +docker exec nextcloud-node-red cat \ + /data/node_modules/node-red-contrib-nextcloud-ocs/package.json | grep version +``` + +### When to delete the volume + +Delete `node-red-data` only if: + +- Palette shows wrong/old nodes after rebuild, or +- Entrypoint copy failed with permission errors from old root-owned files + +**Warning:** removes all flows and credentials. + +```bash +docker rm -f nextcloud-node-red +docker volume rm node-red-data +docker compose up -d +``` + +## 6. Backup + +Back up the Docker volume: + +```bash +docker run --rm -v node-red-data:/data -v $(pwd):/backup alpine \ + tar czf /backup/node-red-data-backup.tar.gz -C /data . +``` + +Restore by extracting into a new volume before first start. + +## 7. Production checklist + +- [ ] Set `credentialSecret` in Node-RED settings (`/data/settings.js`) +- [ ] Use app passwords with minimal needed scopes +- [ ] TLS on Nextcloud; consider TLS on Node-RED if exposed beyond LAN +- [ ] Restrict who can access Node-RED (firewall / VPN / admin-only NC group) +- [ ] Enable Nextcloud app only for trusted admins if flows can access sensitive data diff --git a/docs/NODES.md b/docs/NODES.md new file mode 100644 index 0000000..138aa2d --- /dev/null +++ b/docs/NODES.md @@ -0,0 +1,135 @@ +# Node reference + +Package: **`node-red-contrib-nextcloud-ocs`** (see `nodes/nextcloud-ocs/package.json`). + +All API nodes require a **nextcloud-config** configuration node unless noted. + +## nextcloud-config + +Configuration node — not placed on flows alone. + +| Field | Description | +|-------|-------------| +| Name | Label in the editor | +| Nextcloud URL | Base URL, e.g. `https://cloud.example.com` | +| Username | Nextcloud account name | +| Password | **App password** (stored encrypted) | + +## ocs-api + +Generic OCS/HTTP caller. + +| Config | Default | +|--------|---------| +| Endpoint | `/ocs/v2.php/cloud/user` | +| Method | `GET` | + +**Runtime:** `msg.endpoint`, `msg.method`, `msg.body`, `msg.headers`. + +## collectives + +Nextcloud **Collectives** app. Operations use ids like `collective:list`, `page:create`, `share:updatePageShare`, etc. + +Categories include: collectives, pages, trash, shares, templates, tags, user settings, sessions, public (token) APIs, search. + +~80 operations — see `collectives.js` `OPERATIONS` for the full list. + +## file-operations + +| Operation | Protocol | +|-----------|----------| +| `list`, `get`, `upload`, `mkdir`, `move`, `copy`, `delete` | WebDAV `/remote.php/dav/files/{user}/…` | +| `listShares`, `createShare`, `deleteShare` | OCS sharing API | +| `fileInfo`, `favorites` | OCS files API | + +Path placeholders: `{user}`, `{folder}`, `{path}`, `{id}`. + +## mail + +Mail app OCS endpoints (send, list messages, accounts, etc.). See `mail.js`. + +## tables + +Tables app — tables, views, rows, columns, shares. See `tables.js`. + +## talk + +Talk (Spreed) — rooms, messages, reactions, polls, bots, breakout rooms, etc. Largest node (~80+ operations). See `talk.js`. + +## webhooks + +Webhook Listeners app: + +| Operation | Method | +|-----------|--------| +| `webhook:list` | GET | +| `webhook:get` | GET | +| `webhook:create` | POST | +| `webhook:update` | POST | +| `webhook:delete` | DELETE | +| `webhook:deleteByApp` | DELETE | + +## dashboard + +Dashboard widgets API. See `dashboard.js`. + +## dav + +DAV-related OCS (direct links, out-of-office). See `dav.js`. + +## core + +Nextcloud **core** OCS and related APIs (~50 operations), including: + +- Status, capabilities, app passwords +- Navigation, profile, hover card +- Collaboration resources, references, previews, avatars +- CSRF, login flow v2, wipe, OCM discovery +- Unified search, task processing, text processing +- Translation, text-to-image, teams + +Operation ids use prefixes: `status:`, `capabilities:`, `appPassword:`, `search:`, `task:`, etc. See `core.js`. + +## oauth2 + +OAuth2 client management OCS. See `oauth2.js`. + +## provisioning + +User and group provisioning API. See `provisioning.js`. + +## filesharing + +Dedicated file sharing OCS (beyond WebDAV in file-operations). See `filesharing.js`. + +## userstatus + +User status (emoji/message). See `userstatus.js`. + +## settings + +User and admin settings OCS. See `settings.js`. + +--- + +## Common message properties + +| Property | Used by | +|----------|---------| +| `msg.operation` | All operation-based nodes | +| `msg.payload` | Response body (output) | +| `msg.statusCode` | HTTP status (output) | +| `msg.error` | Error string on failure | + +Path parameters are typically available as `msg.` matching the placeholder (e.g. `msg.collectiveId`, `msg.pageId`, `msg.webhookId`). + +Body fields configured in the editor can often be overridden via `msg.body` object or node-specific `msg.body*` fields — check each node’s `buildBody` / handler in the `.js` file. + +## Adding a new operation + +1. Add entry to `OPERATIONS` in the relevant `.js` file. +2. Add dropdown option and fields in the matching `.html` file. +3. Bump `version` in `package.json`. +4. Rebuild Docker image and restart container. + +For one-off APIs, use **ocs-api** instead of extending a dedicated node. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..069b91c --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,166 @@ +# Troubleshooting + +## Nodes missing from palette + +### Symptom + +Node-RED runs but Nextcloud nodes do not appear under **Manage palette → Nodes**. + +### Checks + +1. **Entrypoint ran** + + ```bash + docker logs nextcloud-node-red 2>&1 | head -20 + ``` + + Expect: + + ``` + [entrypoint] Installing nextcloud nodes into /data... + [entrypoint] Source: /opt/nextcloud-nodes/node-red-contrib-nextcloud-ocs + [entrypoint] Target: /data/node_modules/node-red-contrib-nextcloud-ocs + ``` + + If you only see the Node-RED banner with no `[entrypoint]` lines, the image may still use `CMD` instead of `ENTRYPOINT ["/entrypoint.sh"]` — rebuild from current `Dockerfile`. + +2. **Module installed in userDir** + + ```bash + docker exec nextcloud-node-red ls /data/node_modules/node-red-contrib-nextcloud-ocs + docker exec nextcloud-node-red cat /data/node_modules/node-red-contrib-nextcloud-ocs/package.json + ``` + +3. **User directory** + + Logs must show `User directory : /data`. If it shows `/usr/src/node-red/.node-red/`, entrypoint did not start Node-RED with `--userDir /data`. + +4. **Stale volume (old package version)** + + Compare `/opt` vs `/data`: + + ```bash + docker exec nextcloud-node-red sh -c \ + 'grep version /opt/nextcloud-nodes/node-red-contrib-nextcloud-ocs/package.json; \ + grep version /data/node_modules/node-red-contrib-nextcloud-ocs/package.json' + ``` + + If `/opt` is newer, restart should copy on boot. If `/data` stays old, remove volume (loses flows): + + ```bash + docker rm -f nextcloud-node-red + docker volume rm node-red-data + docker run -d --name nextcloud-node-red --network host \ + -v node-red-data:/data --restart unless-stopped nextcloud-node-red:latest + ``` + +5. **Browser cache** + + Hard refresh (Ctrl+F5) or open Node-RED in a private window. + +--- + +## Container restart loop + +### Symptom + +`docker ps` shows `Restarting`; logs repeat entrypoint lines. + +### Cause + +Old `entrypoint.sh` tried to copy missing `settings.js`: + +``` +cp: can't stat '/usr/src/node-red/.node-red/settings.js': No such file or directory +``` + +### Fix + +Use current `entrypoint.sh` (starts `node-red --userDir /data` only). Rebuild: + +```bash +docker build --no-cache -t nextcloud-node-red:latest . +``` + +--- + +## `No Nextcloud configuration node selected` + +Flow nodes reference a deleted or unconfigured **nextcloud-config** node. Open each red Nextcloud node and re-select the config node, then Deploy. + +--- + +## API errors / connection refused + +| Issue | Fix | +|-------|-----| +| `ECONNREFUSED` to localhost | Use `--network host` or use LAN IP / hostname, not `127.0.0.1` from bridge network | +| SSL errors | Self-signed: nodes set `rejectUnauthorized: false`; or install proper certs | +| 401 Unauthorized | Use **app password**, correct username | +| 404 on OCS path | App not installed/enabled on Nextcloud (Collectives, Talk, etc.) | + +Test from container: + +```bash +docker exec nextcloud-node-red wget -qO- --header="OCS-APIRequest: true" \ + --user='USER:APP_PASSWORD' \ + https://cloud.example.com/ocs/v2.php/cloud/user +``` + +--- + +## Docker build shows old files + +Build context must include updated `nodes/nextcloud-ocs/`: + +```bash +grep version nodes/nextcloud-ocs/package.json # on host before build +docker build --no-cache -t nextcloud-node-red:latest . +``` + +`docker builder prune -af` clears COPY layer cache. + +--- + +## Nextcloud iframe blank + +1. Browser DevTools → Console: CSP violations. +2. Confirm **nodered_embed** is enabled and admin URL matches iframe target host. +3. `CSPListener` adds `frame-src` for the hostname from `nodered_url`. +4. Reverse proxy must not set `X-Frame-Options: DENY` on Node-RED. + +```bash +curl -I https://cloud.example.com | grep -i frame +curl -I http://192.168.1.26:1880 | grep -i frame +``` + +--- + +## Permission denied on entrypoint copy + +Old files in volume owned by root. Fix: + +```bash +docker rm -f nextcloud-node-red +docker volume rm node-red-data +# recreate container +``` + +Or fix ownership once: + +```bash +docker exec -u root nextcloud-node-red chown -R node-red:node-red /data +``` + +--- + +## Verify node discovery manually + +```bash +docker exec nextcloud-node-red node -e " + const p=require('/data/node_modules/node-red-contrib-nextcloud-ocs/package.json'); + console.log(p.version, Object.keys(p['node-red'].nodes).length, 'nodes'); +" +``` + +Expected: version from `package.json` and **16** node types. diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..fbbb7b6 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# Clean and install Nextcloud OCS nodes into /data at runtime. +# /data is a Docker volume that masks build-time COPY. + +set -eu + +echo "[entrypoint] Installing nextcloud nodes into /data..." + +SRC="/opt/nextcloud-nodes/node-red-contrib-nextcloud-ocs" +DST_DIR="/data/node_modules" +DST="$DST_DIR/node-red-contrib-nextcloud-ocs" + +echo "[entrypoint] Source: $SRC" +echo "[entrypoint] Target: $DST" + +if [ ! -d "$SRC" ]; then + echo "[entrypoint] ERROR: source directory missing: $SRC" >&2 + ls -la /opt || true + ls -la /opt/nextcloud-nodes || true + exit 1 +fi + +mkdir -p "$DST_DIR" +rm -rf "$DST" +cp -a "$SRC" "$DST_DIR/" + +if [ ! -f "$DST/package.json" ]; then + echo "[entrypoint] ERROR: install did not produce $DST/package.json" >&2 + echo "[entrypoint] Debug: listing /data and /data/node_modules" >&2 + ls -la /data || true + ls -la "$DST_DIR" || true + exit 1 +fi + +# Ensure Node-RED can read editor files and load nodes as the non-root user. +chown -R node-red:node-red /data "$DST_DIR" "$DST" || true +chmod -R a+rX "$DST" || true + +export NODE_RED_USER_DIR=/data + +# Start Node-RED explicitly with /data as userDir so it discovers +# nodes from /data/node_modules/** based on each module's package.json. +exec node-red --userDir /data \ No newline at end of file diff --git a/nodes/nextcloud-ocs/collectives.html b/nodes/nextcloud-ocs/collectives.html new file mode 100644 index 0000000..3261225 --- /dev/null +++ b/nodes/nextcloud-ocs/collectives.html @@ -0,0 +1,477 @@ + + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/collectives.js b/nodes/nextcloud-ocs/collectives.js new file mode 100644 index 0000000..3ed7b10 --- /dev/null +++ b/nodes/nextcloud-ocs/collectives.js @@ -0,0 +1,303 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + // Operation definitions: operationId -> { method, pathTemplate, queryParams } + var OPERATIONS = { + // --- collective --- + 'collective:list': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives' }, + 'collective:create': { method: 'POST', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives', body: ['name','emoji'] }, + 'collective:update': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{id}', body: ['emoji'] }, + 'collective:trash': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{id}' }, + 'collective:editLevel': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{id}/editLevel', body: ['level'] }, + 'collective:shareLevel': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{id}/shareLevel', body: ['level'] }, + 'collective:pageMode': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{id}/pageMode', body: ['mode'] }, + // --- trash --- + 'trash:list': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/trash' }, + 'trash:restore': { method: 'PATCH', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/trash/{id}' }, + 'trash:delete': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/trash/{id}', query: ['circle'] }, + // --- share --- + 'share:listCollectiveShares': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/shares' }, + 'share:createCollectiveShare': { method: 'POST', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/shares', body: ['password'] }, + 'share:updateCollectiveShare': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/shares/{token}', body: ['editable','password'] }, + 'share:deleteCollectiveShare': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/shares/{token}' }, + 'share:createPageShare': { method: 'POST', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{pageId}/shares', body: ['password'] }, + 'share:updatePageShare': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{pageId}/shares/{token}', body: ['editable','password'] }, + 'share:deletePageShare': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{pageId}/shares/{token}' }, + // --- search --- + 'search:recentPages': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/search/recent', query: ['query','limit'] }, + 'search:content': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/search', query: ['searchString'] }, + // --- page --- + 'page:list': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages' }, + 'page:get': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}' }, + 'page:create': { method: 'POST', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{parentId}', body: ['title','templateId'] }, + 'page:moveOrCopy': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}', body: ['parentId','title','index','copy'] }, + 'page:trash': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}' }, + 'page:touch': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}/touch' }, + 'page:fullWidth': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}/fullWidth', body: ['fullWidth'] }, + 'page:emoji': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}/emoji', body: ['emoji'] }, + 'page:subpageOrder': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}/subpageOrder', body: ['subpageOrder'] }, + 'page:moveToCollective': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}/to/{newCollectiveId}', body: ['parentId','index','copy'] }, + 'page:addTag': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}/tags/{tagId}' }, + 'page:removeTag': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}/tags/{tagId}' }, + 'page:listAttachments': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}/attachments' }, + 'page:uploadAttachment': { method: 'POST', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}/attachments' }, + 'page:renameAttachment': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}/attachments/{attachmentId}', body: ['name'] }, + 'page:deleteAttachment': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}/attachments/{attachmentId}' }, + 'page:restoreAttachment': { method: 'PATCH', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/{id}/attachments/trash/{attachmentId}' }, + // --- page_trash --- + 'page_trash:list': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/trash' }, + 'page_trash:restore': { method: 'PATCH', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/trash/{id}' }, + 'page_trash:delete': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/trash/{id}' }, + // --- template --- + 'template:list': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/templates' }, + 'template:create': { method: 'POST', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/templates/{id}', body: ['title','parentId'] }, + 'template:rename': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/templates/{id}', body: ['title'] }, + 'template:delete': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/templates/{id}' }, + 'template:emoji': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/pages/templates/{id}/emoji', body: ['emoji'] }, + // --- tag --- + 'tag:list': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/tags' }, + 'tag:create': { method: 'POST', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/tags', body: ['name','color'] }, + 'tag:update': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/tags/{id}', body: ['name','color'] }, + 'tag:delete': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/tags/{id}' }, + // --- collective_user_settings --- + 'userSettings:pageOrder': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/userSettings/pageOrder', body: ['pageOrder'] }, + 'userSettings:showMembers': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/userSettings/showMembers', body: ['showMembers'] }, + 'userSettings:showRecentPages':{ method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/userSettings/showRecentPages', body: ['showRecentPages'] }, + 'userSettings:favoritePages': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/userSettings/favoritePages', body: ['favoritePages'] }, + // --- session --- + 'session:create': { method: 'POST', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/sessions' }, + 'session:sync': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/sessions', body: ['token'] }, + 'session:close': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/collectives/{collectiveId}/sessions', query: ['token'] }, + // --- settings --- + 'settings:get': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/settings/user/{key}' }, + 'settings:set': { method: 'POST', path: '/ocs/v2.php/apps/collectives/api/v1.0/settings/user', body: ['key','value'] }, + // --- public_collective --- + 'public:getCollective': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}' }, + // --- public_page --- + 'public:listPages': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages' }, + 'public:getPage': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}' }, + 'public:createPage': { method: 'POST', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{parentId}', body: ['title','templateId'] }, + 'public:moveOrCopyPage': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}', body: ['parentId','title','index','copy'] }, + 'public:trashPage': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}' }, + 'public:touchPage': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}/touch' }, + 'public:fullWidth': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}/fullWidth', body: ['fullWidth'] }, + 'public:emoji': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}/emoji', body: ['emoji'] }, + 'public:subpageOrder': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}/subpageOrder', body: ['subpageOrder'] }, + 'public:addTag': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}/tags/{tagId}' }, + 'public:removeTag': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}/tags/{tagId}' }, + 'public:listAttachments': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}/attachments' }, + 'public:uploadAttachment': { method: 'POST', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}/attachments' }, + 'public:renameAttachment': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}/attachments/{attachmentId}', body: ['name'] }, + 'public:deleteAttachment': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}/attachments/{attachmentId}' }, + 'public:restoreAttachment': { method: 'PATCH', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/{id}/attachments/trash/{attachmentId}' }, + 'public:searchContent': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/search', query: ['searchString'] }, + // --- public_page_trash --- + 'public:listTrash': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/trash' }, + 'public:restoreTrash': { method: 'PATCH', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/trash/{id}' }, + 'public:deleteTrash': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/trash/{id}' }, + // --- public_template --- + 'public:listTemplates': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/pages/templates' }, + // --- public_tag --- + 'public:listTags': { method: 'GET', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/tags' }, + 'public:createTag': { method: 'POST', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/tags', body: ['name','color'] }, + 'public:updateTag': { method: 'PUT', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/tags/{id}', body: ['name','color'] }, + 'public:deleteTag': { method: 'DELETE', path: '/ocs/v2.php/apps/collectives/api/v1.0/p/collectives/{token}/tags/{id}' } + }; + + function CollectivesNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'collective:list'; + + // Path parameter fields from config + this.collectiveId = config.collectiveId; + this.pageId = config.pageId; + this.token = config.token; + this.parentId = config.parentId; + this.tagId = config.tagId; + this.attachmentId = config.attachmentId; + this.newCollectiveId = config.newCollectiveId; + this.itemId = config.itemId; // generic {id} + this.settingKey = config.settingKey; + + // Body fields + this.bodyName = config.bodyName; + this.bodyEmoji = config.bodyEmoji; + this.bodyColor = config.bodyColor; + this.bodyLevel = config.bodyLevel; + this.bodyMode = config.bodyMode; + this.bodyTitle = config.bodyTitle; + this.bodyEditable = config.bodyEditable; + this.bodyPassword = config.bodyPassword; + this.bodyFullWidth = config.bodyFullWidth; + this.bodyTemplateId = config.bodyTemplateId; + this.bodyPageOrder = config.bodyPageOrder; + this.bodyShowMembers = config.bodyShowMembers; + this.bodyShowRecentPages = config.bodyShowRecentPages; + this.bodyFavoritePages = config.bodyFavoritePages; + this.bodyIndex = config.bodyIndex; + this.bodyCopy = config.bodyCopy; + this.bodySubpageOrder = config.bodySubpageOrder; + this.bodyParentId = config.bodyParentId; + this.bodyCircle = config.bodyCircle; + this.bodySearchString = config.bodySearchString; + this.bodyQuery = config.bodyQuery; + this.bodyLimit = config.bodyLimit; + this.bodyKey = config.bodyKey; + this.bodyValue = config.bodyValue; + this.bodySessionToken = config.bodySessionToken; + + if (!this.configNode) { + node.error("No Nextcloud configuration node selected"); + return; + } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { + node.error("Unknown operation: " + (msg.operation || node.operation), msg); + return; + } + + var baseUrl = (node.configNode.baseUrl || '').replace(/\/+$/, ''); + var username = node.configNode.credentials.username; + var password = node.configNode.credentials.password; + + // Build path with substituted parameters (msg overrides config) + var path = op.path; + var subs = { + '{id}': msg.itemId || node.itemId || msg.pageId || node.pageId || '', + '{collectiveId}': msg.collectiveId || node.collectiveId || '', + '{pageId}': msg.pageId || node.pageId || '', + '{parentId}': msg.parentId || node.parentId || '', + '{token}': msg.token || node.token || '', + '{tagId}': msg.tagId || node.tagId || '', + '{attachmentId}': msg.attachmentId || node.attachmentId || '', + '{newCollectiveId}': msg.newCollectiveId || node.newCollectiveId || '', + '{key}': msg.settingKey || node.settingKey || '' + }; + Object.keys(subs).forEach(function(k) { + path = path.replace(k, subs[k]); + }); + + // Query parameters + var queryParts = []; + var queryDefs = msg.query || {}; + if (op.query) { + op.query.forEach(function(q) { + var val = msg[q] !== undefined ? msg[q] : getBodyField(node, q); + if (val !== undefined && val !== '') { + queryParts.push(encodeURIComponent(q) + '=' + encodeURIComponent(val)); + } + }); + } + // Also handle query overrides from msg + if (typeof msg.query === 'object') { + Object.keys(msg.query).forEach(function(k) { + if (op.query && op.query.indexOf(k) === -1) { + queryParts.push(encodeURIComponent(k) + '=' + encodeURIComponent(msg.query[k])); + } + }); + } + var fullUrl = baseUrl + path; + if (queryParts.length) { + fullUrl += '?' + queryParts.join('&'); + } + + var parsedUrl = url.parse(fullUrl); + + var headers = { + 'OCS-APIRequest': 'true', + 'Accept': 'application/json', + 'Authorization': 'Basic ' + Buffer.from(username + ':' + password).toString('base64') + }; + + // Build request body + var bodyObj = msg.body || {}; + if (op.body) { + op.body.forEach(function(b) { + var val = msg[b] !== undefined ? msg[b] : getBodyField(node, b); + if (val !== undefined && val !== '') { + bodyObj[b] = val; + } + }); + } + + var bodyStr = null; + if (Object.keys(bodyObj).length > 0) { + headers['Content-Type'] = 'application/json'; + bodyStr = JSON.stringify(bodyObj); + } + // Allow raw body override + if (msg.rawBody) { + bodyStr = typeof msg.rawBody === 'string' ? msg.rawBody : JSON.stringify(msg.rawBody); + if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'; + } + + if (msg.headers && typeof msg.headers === 'object') { + Object.keys(msg.headers).forEach(function(k) { + headers[k] = msg.headers[k]; + }); + } + + var opts = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), + path: parsedUrl.path, + method: op.method, + headers: headers, + rejectUnauthorized: false + }; + + var transport = parsedUrl.protocol === 'https:' ? https : http; + + node.log(op.method + ' ' + fullUrl); + + var req = transport.request(opts, function(res) { + var body = ''; + res.on('data', function(chunk) { body += chunk; }); + res.on('end', function() { + msg.statusCode = res.statusCode; + msg.operation = node.operation; + try { + msg.payload = JSON.parse(body); + } catch(e) { + msg.payload = body; + } + node.send(msg); + }); + }); + + req.on('error', function(err) { + msg.error = err.message; + node.error(op.method + ' ' + fullUrl + ' — ' + err.message, msg); + }); + + if (bodyStr) { + req.write(bodyStr); + } + req.end(); + }); + } + + function getBodyField(node, name) { + var map = { + 'name': node.bodyName, 'emoji': node.bodyEmoji, 'color': node.bodyColor, + 'level': node.bodyLevel, 'mode': node.bodyMode, 'title': node.bodyTitle, + 'editable': node.bodyEditable, 'password': node.bodyPassword, + 'fullWidth': node.bodyFullWidth, 'templateId': node.bodyTemplateId, + 'pageOrder': node.bodyPageOrder, 'showMembers': node.bodyShowMembers, + 'showRecentPages': node.bodyShowRecentPages, 'favoritePages': node.bodyFavoritePages, + 'index': node.bodyIndex, 'copy': node.bodyCopy, 'subpageOrder': node.bodySubpageOrder, + 'parentId': node.bodyParentId, 'circle': node.bodyCircle, + 'searchString': node.bodySearchString, 'query': node.bodyQuery, 'limit': node.bodyLimit, + 'key': node.bodyKey, 'value': node.bodyValue, 'token': node.bodySessionToken + }; + return map[name]; + } + + RED.nodes.registerType("collectives", CollectivesNode); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/core.html b/nodes/nextcloud-ocs/core.html new file mode 100644 index 0000000..ba68218 --- /dev/null +++ b/nodes/nextcloud-ocs/core.html @@ -0,0 +1,34 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/core.js b/nodes/nextcloud-ocs/core.js new file mode 100644 index 0000000..f63a865 --- /dev/null +++ b/nodes/nextcloud-ocs/core.js @@ -0,0 +1,167 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + // Status + 'status:get': { method:'GET', path:'/status.php' }, + // Capabilities + 'capabilities:get': { method:'GET', path:'/ocs/v2.php/cloud/capabilities' }, + // App password + 'appPassword:get': { method:'GET', path:'/ocs/v2.php/core/getapppassword' }, + 'appPassword:oneTime': { method:'GET', path:'/ocs/v2.php/core/getapppassword-onetime' }, + 'appPassword:delete': { method:'DELETE', path:'/ocs/v2.php/core/apppassword' }, + 'appPassword:rotate': { method:'POST', path:'/ocs/v2.php/core/apppassword/rotate' }, + 'appPassword:confirm': { method:'PUT', path:'/ocs/v2.php/core/apppassword/confirm', body:['password'] }, + // Autocomplete + 'autocomplete:get': { method:'GET', path:'/ocs/v2.php/core/autocomplete/get', query:['search','itemType','itemId','sorter','limit'] }, + // Navigation + 'nav:apps': { method:'GET', path:'/ocs/v2.php/core/navigation/apps', query:['absolute'] }, + 'nav:settings': { method:'GET', path:'/ocs/v2.php/core/navigation/settings', query:['absolute'] }, + // Profile + 'profile:get': { method:'GET', path:'/ocs/v2.php/profile/{userId}' }, + 'profile:setVisibility': { method:'PUT', path:'/ocs/v2.php/profile/{userId}', body:['paramId','visibility'] }, + // Hover card + 'hovercard:get': { method:'GET', path:'/ocs/v2.php/hovercard/v1/{userId}' }, + // Collaboration resources + 'collab:get': { method:'GET', path:'/ocs/v2.php/collaboration/resources/collections/{collectionId}' }, + 'collab:addResource': { method:'POST', path:'/ocs/v2.php/collaboration/resources/collections/{collectionId}', body:['resourceType','resourceId'] }, + 'collab:removeResource': { method:'DELETE', path:'/ocs/v2.php/collaboration/resources/collections/{collectionId}', query:['resourceType','resourceId'] }, + 'collab:rename': { method:'PUT', path:'/ocs/v2.php/collaboration/resources/collections/{collectionId}', body:['collectionName'] }, + 'collab:search': { method:'GET', path:'/ocs/v2.php/collaboration/resources/collections/search/{filter}' }, + 'collab:byResource': { method:'GET', path:'/ocs/v2.php/collaboration/resources/{resourceType}/{resourceId}' }, + 'collab:create': { method:'POST', path:'/ocs/v2.php/collaboration/resources/{baseResourceType}/{baseResourceId}', body:['name'] }, + // References + 'ref:extract': { method:'POST', path:'/ocs/v2.php/references/extract', body:['text','resolve','limit'] }, + 'ref:resolve': { method:'GET', path:'/ocs/v2.php/references/resolve', query:['reference'] }, + 'ref:resolveMany': { method:'POST', path:'/ocs/v2.php/references/resolve', body:['references','limit'] }, + 'ref:providers': { method:'GET', path:'/ocs/v2.php/references/providers' }, + 'ref:touchProvider': { method:'PUT', path:'/ocs/v2.php/references/provider/{providerId}', body:['timestamp'] }, + // Preview + 'preview:byPath': { method:'GET', path:'/index.php/core/preview.png', query:['file','x','y','a','forceIcon','mode'] }, + 'preview:byFileId': { method:'GET', path:'/index.php/core/preview', query:['fileId','x','y','a','forceIcon','mode'] }, + // Avatars + 'avatar:user': { method:'GET', path:'/index.php/avatar/{userId}/{size}' }, + 'avatar:userDark': { method:'GET', path:'/index.php/avatar/{userId}/{size}/dark' }, + 'avatar:guest': { method:'GET', path:'/index.php/avatar/guest/{guestName}/{size}' }, + // CSRF + 'csrf:get': { method:'GET', path:'/index.php/csrftoken' }, + // Login flow + 'login:init': { method:'POST', path:'/index.php/login/v2' }, + 'login:poll': { method:'POST', path:'/index.php/login/v2/poll', body:['token'] }, + 'login:confirm': { method:'POST', path:'/index.php/login/confirm', body:['password'] }, + // Wipe + 'wipe:check': { method:'POST', path:'/index.php/core/wipe/check', body:['token'] }, + 'wipe:done': { method:'POST', path:'/index.php/core/wipe/success', body:['token'] }, + // OCM + 'ocm:discovery': { method:'GET', path:'/index.php/ocm-provider' }, + // Unified search + 'search:providers': { method:'GET', path:'/ocs/v2.php/search/providers', query:['from'] }, + 'search:execute': { method:'GET', path:'/ocs/v2.php/search/providers/{providerId}/search', query:['term','sortOrder','limit','cursor','from'] }, + // Task processing + 'task:types': { method:'GET', path:'/ocs/v2.php/taskprocessing/tasktypes' }, + 'task:schedule': { method:'POST', path:'/ocs/v2.php/taskprocessing/schedule', body:['input','type','appId','customId'] }, + 'task:get': { method:'GET', path:'/ocs/v2.php/taskprocessing/task/{id}' }, + 'task:delete': { method:'DELETE', path:'/ocs/v2.php/taskprocessing/task/{id}' }, + 'task:list': { method:'GET', path:'/ocs/v2.php/taskprocessing/tasks', query:['taskType','customId'] }, + 'task:listByApp': { method:'GET', path:'/ocs/v2.php/taskprocessing/tasks/app/{appId}', query:['customId'] }, + 'task:cancel': { method:'POST', path:'/ocs/v2.php/taskprocessing/tasks/{taskId}/cancel' }, + // Text processing + 'text:types': { method:'GET', path:'/ocs/v2.php/textprocessing/tasktypes' }, + 'text:schedule': { method:'POST', path:'/ocs/v2.php/textprocessing/schedule', body:['input','type','appId','identifier'] }, + 'text:get': { method:'GET', path:'/ocs/v2.php/textprocessing/task/{id}' }, + 'text:delete': { method:'DELETE', path:'/ocs/v2.php/textprocessing/task/{id}' }, + 'text:listByApp': { method:'GET', path:'/ocs/v2.php/textprocessing/tasks/app/{appId}', query:['identifier'] }, + // Translation + 'translate:languages': { method:'GET', path:'/ocs/v2.php/translation/languages' }, + 'translate:text': { method:'POST', path:'/ocs/v2.php/translation/translate', body:['text','fromLanguage','toLanguage'] }, + // Text to image + 't2i:available': { method:'GET', path:'/ocs/v2.php/text2image/is_available' }, + 't2i:schedule': { method:'POST', path:'/ocs/v2.php/text2image/schedule', body:['input','appId','identifier','numberOfImages'] }, + 't2i:get': { method:'GET', path:'/ocs/v2.php/text2image/task/{id}' }, + 't2i:delete': { method:'DELETE', path:'/ocs/v2.php/text2image/task/{id}' }, + 't2i:image': { method:'GET', path:'/ocs/v2.php/text2image/task/{id}/image/{index}' }, + 't2i:listByApp': { method:'GET', path:'/ocs/v2.php/text2image/tasks/app/{appId}', query:['identifier'] }, + // Teams + 'teams:resources': { method:'GET', path:'/ocs/v2.php/teams/{teamId}/resources' }, + 'teams:ofResource': { method:'GET', path:'/ocs/v2.php/teams/resources/{providerId}/{resourceId}' } + }; + + function CoreNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'status:get'; + this.userId = config.userId; this.collectionId = config.collectionId; + this.resourceType = config.resourceType; this.resourceId = config.resourceId; + this.providerId = config.providerId; this.appId = config.appId; + this.taskId = config.taskId; this.teamId = config.teamId; + this.guestName = config.guestName; this.size = config.size; + this.filter = config.filter; this.baseResourceType = config.baseResourceType; + this.baseResourceId = config.baseResourceId; + + if (!this.configNode) { node.error("No NC config node"); return; } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { node.error("Unknown: "+(msg.operation||node.operation), msg); return; } + + var baseUrl = (node.configNode.baseUrl||'').replace(/\/+$/,''); + var uname = node.configNode.credentials.username; + var pw = node.configNode.credentials.password; + + var path = op.path + .replace(/\{userId\}/g, msg.userId||node.userId||'') + .replace(/\{collectionId\}/g, msg.collectionId||node.collectionId||'') + .replace(/\{resourceType\}/g, msg.resourceType||node.resourceType||'') + .replace(/\{resourceId\}/g, msg.resourceId||node.resourceId||'') + .replace(/\{providerId\}/g, msg.providerId||node.providerId||'') + .replace(/\{appId\}/g, msg.appId||node.appId||'') + .replace(/\{id\}/g, msg.taskId||node.taskId||msg.id||'') + .replace(/\{taskId\}/g, msg.taskId||node.taskId||'') + .replace(/\{teamId\}/g, msg.teamId||node.teamId||'') + .replace(/\{guestName\}/g, msg.guestName||node.guestName||'') + .replace(/\{size\}/g, msg.size||node.size||'64') + .replace(/\{filter\}/g, msg.filter||node.filter||'') + .replace(/\{baseResourceType\}/g, msg.baseResourceType||node.baseResourceType||'') + .replace(/\{baseResourceId\}/g, msg.baseResourceId||node.baseResourceId||'') + .replace(/\{index\}/g, msg.index||'0'); + + var qp = []; + if (op.query) op.query.forEach(function(q) { + var v = msg[q]!==undefined ? msg[q] : getF(node,q); + if (v!==undefined&&v!=='') qp.push(encodeURIComponent(q)+'='+encodeURIComponent(v)); + }); + var fu = baseUrl+path+(qp.length?'?'+qp.join('&'):''); + var pu = url.parse(fu); + + var h = {'Accept':'application/json', + 'Authorization':'Basic '+Buffer.from(uname+':'+pw).toString('base64')}; + if (path.indexOf('/ocs/')===0) h['OCS-APIRequest']='true'; + + var bo={}; + if (op.body) op.body.forEach(function(b){ + var v=msg[b]!==undefined?msg[b]:getF(node,b); + if(v!==undefined&&v!=='') bo[b]=v; + }); + var bs=null; + if (Object.keys(bo).length>0) {h['Content-Type']='application/json'; bs=JSON.stringify(bo);} + + var o = {hostname:pu.hostname,port:pu.port||(pu.protocol==='https:'?443:80), + path:pu.path,method:op.method,headers:h,rejectUnauthorized:false}; + node.log(op.method+' '+fu); + var t = pu.protocol==='https:'?https:http; + var r = t.request(o,function(res){ + var b='';res.on('data',function(c){b+=c;}); + res.on('end',function(){msg.statusCode=res.statusCode;msg.operation=msg.operation||node.operation; + try{msg.payload=JSON.parse(b);}catch(e){msg.payload=b;}node.send(msg);}); + }); + r.on('error',function(e){msg.error=e.message;node.error(op.method+' '+fu+' - '+e.message,msg);}); + if(bs) r.write(bs); + r.end(); + }); + } + function getF(node,n){var m={'password':node.bodyPassword,'text':node.bodyText,'search':node.bodySearch};return m[n];} + RED.nodes.registerType("core", CoreNode); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/dashboard.html b/nodes/nextcloud-ocs/dashboard.html new file mode 100644 index 0000000..f8baef5 --- /dev/null +++ b/nodes/nextcloud-ocs/dashboard.html @@ -0,0 +1,29 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/dashboard.js b/nodes/nextcloud-ocs/dashboard.js new file mode 100644 index 0000000..2219f09 --- /dev/null +++ b/nodes/nextcloud-ocs/dashboard.js @@ -0,0 +1,81 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + 'widgets:list': { method:'GET', path:'/ocs/v2.php/apps/dashboard/api/v1/widgets' }, + 'widgetItems:v1': { method:'GET', path:'/ocs/v2.php/apps/dashboard/api/v1/widget-items', query:['sinceIds','limit'] }, + 'widgetItems:v2': { method:'GET', path:'/ocs/v2.php/apps/dashboard/api/v2/widget-items', query:['sinceIds','limit'] }, + 'layout:get': { method:'GET', path:'/ocs/v2.php/apps/dashboard/api/v3/layout' }, + 'layout:update': { method:'POST', path:'/ocs/v2.php/apps/dashboard/api/v3/layout', body:['layout'] }, + 'statuses:get': { method:'GET', path:'/ocs/v2.php/apps/dashboard/api/v3/statuses' }, + 'statuses:update': { method:'POST', path:'/ocs/v2.php/apps/dashboard/api/v3/statuses', body:['statuses'] } + }; + + function DashboardNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'widgets:list'; + this.bodySinceIds = config.bodySinceIds; this.bodyLimit = config.bodyLimit; + this.bodyLayout = config.bodyLayout; this.bodyStatuses = config.bodyStatuses; + + if (!this.configNode) { node.error("No Nextcloud configuration node selected"); return; } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { node.error("Unknown operation: " + (msg.operation || node.operation), msg); return; } + + var baseUrl = (node.configNode.baseUrl || '').replace(/\/+$/, ''); + var username = node.configNode.credentials.username; + var password = node.configNode.credentials.password; + var path = op.path; + + var queryParts = []; + if (op.query) { + op.query.forEach(function(q) { + var v = msg[q] !== undefined ? msg[q] : getField(node, q); + if (v !== undefined && v !== '') queryParts.push(encodeURIComponent(q) + '=' + encodeURIComponent(v)); + }); + } + var fullUrl = baseUrl + path + (queryParts.length ? '?' + queryParts.join('&') : ''); + var parsedUrl = url.parse(fullUrl); + + var headers = { 'OCS-APIRequest':'true','Accept':'application/json', + 'Authorization':'Basic '+Buffer.from(username+':'+password).toString('base64') }; + + var bodyObj = {}; + if (op.body) { + op.body.forEach(function(b) { + var v = msg[b] !== undefined ? msg[b] : getField(node, b); + if (v !== undefined && v !== '') bodyObj[b] = v; + }); + } + if (msg.layout && Array.isArray(msg.layout)) bodyObj.layout = msg.layout; + if (msg.statuses && Array.isArray(msg.statuses)) bodyObj.statuses = msg.statuses; + + var bodyStr = null; + if (Object.keys(bodyObj).length > 0) { headers['Content-Type']='application/json'; bodyStr=JSON.stringify(bodyObj); } + + var opts = { hostname:parsedUrl.hostname, port:parsedUrl.port||(parsedUrl.protocol==='https:'?443:80), + path:parsedUrl.path, method:op.method, headers:headers, rejectUnauthorized:false }; + + node.log(op.method+' '+fullUrl); + var transport = parsedUrl.protocol==='https:'?https:http; + var req = transport.request(opts, function(res) { + var body=''; res.on('data',function(c){body+=c;}); + res.on('end',function(){ msg.statusCode=res.statusCode; msg.operation=msg.operation||node.operation; + try{msg.payload=JSON.parse(body);}catch(e){msg.payload=body;} node.send(msg); }); + }); + req.on('error',function(err){msg.error=err.message;node.error(op.method+' '+fullUrl+' — '+err.message,msg);}); + if(bodyStr) req.write(bodyStr); + req.end(); + }); + } + function getField(node,name) { + var m={ 'sinceIds':node.bodySinceIds,'limit':node.bodyLimit,'layout':node.bodyLayout,'statuses':node.bodyStatuses }; + return m[name]; + } + RED.nodes.registerType("dashboard", DashboardNode); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/dav.html b/nodes/nextcloud-ocs/dav.html new file mode 100644 index 0000000..8b4302a --- /dev/null +++ b/nodes/nextcloud-ocs/dav.html @@ -0,0 +1,37 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/dav.js b/nodes/nextcloud-ocs/dav.js new file mode 100644 index 0000000..938e381 --- /dev/null +++ b/nodes/nextcloud-ocs/dav.js @@ -0,0 +1,85 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + 'direct:url': { method:'POST', path:'/ocs/v2.php/apps/dav/api/v1/direct', body:['fileId','expirationTime'] }, + 'events:upcoming': { method:'GET', path:'/ocs/v2.php/apps/dav/api/v1/events/upcoming', query:['location'] }, + 'ooo:current': { method:'GET', path:'/ocs/v2.php/apps/dav/api/v1/outOfOffice/{userId}/now' }, + 'ooo:get': { method:'GET', path:'/ocs/v2.php/apps/dav/api/v1/outOfOffice/{userId}' }, + 'ooo:set': { method:'POST', path:'/ocs/v2.php/apps/dav/api/v1/outOfOffice/{userId}', body:['firstDay','lastDay','status','message','replacementUserId'] }, + 'ooo:clear': { method:'DELETE',path:'/ocs/v2.php/apps/dav/api/v1/outOfOffice/{userId}' } + }; + + function DavNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'direct:url'; + this.userId = config.userId; + this.bodyFileId = config.bodyFileId; this.bodyExpirationTime = config.bodyExpirationTime; + this.bodyFirstDay = config.bodyFirstDay; this.bodyLastDay = config.bodyLastDay; + this.bodyStatus = config.bodyStatus; this.bodyMessage = config.bodyMessage; + this.bodyReplacementUserId = config.bodyReplacementUserId; + this.bodyLocation = config.bodyLocation; + + if (!this.configNode) { node.error("No Nextcloud configuration node selected"); return; } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { node.error("Unknown operation: " + (msg.operation || node.operation), msg); return; } + + var baseUrl = (node.configNode.baseUrl || '').replace(/\/+$/, ''); + var username = node.configNode.credentials.username; + var password = node.configNode.credentials.password; + + var path = op.path.replace('{userId}', msg.userId || node.userId || ''); + + var queryParts = []; + if (op.query) { + op.query.forEach(function(q) { + var v = msg[q] !== undefined ? msg[q] : getField(node, q); + if (v !== undefined && v !== '') queryParts.push(encodeURIComponent(q) + '=' + encodeURIComponent(v)); + }); + } + var fullUrl = baseUrl + path + (queryParts.length ? '?' + queryParts.join('&') : ''); + var parsedUrl = url.parse(fullUrl); + + var headers = { 'OCS-APIRequest':'true','Accept':'application/json', + 'Authorization':'Basic '+Buffer.from(username+':'+password).toString('base64') }; + + var bodyObj = {}; + if (op.body) { + op.body.forEach(function(b) { + var v = msg[b] !== undefined ? msg[b] : getField(node, b); + if (v !== undefined && v !== '') bodyObj[b] = v; + }); + } + + var bodyStr = null; + if (Object.keys(bodyObj).length > 0) { headers['Content-Type']='application/json'; bodyStr=JSON.stringify(bodyObj); } + + var opts = { hostname:parsedUrl.hostname, port:parsedUrl.port||(parsedUrl.protocol==='https:'?443:80), + path:parsedUrl.path, method:op.method, headers:headers, rejectUnauthorized:false }; + + node.log(op.method+' '+fullUrl); + var transport = parsedUrl.protocol==='https:'?https:http; + var req = transport.request(opts, function(res) { + var body=''; res.on('data',function(c){body+=c;}); + res.on('end',function(){ msg.statusCode=res.statusCode; msg.operation=msg.operation||node.operation; + try{msg.payload=JSON.parse(body);}catch(e){msg.payload=body;} node.send(msg); }); + }); + req.on('error',function(err){msg.error=err.message;node.error(op.method+' '+fullUrl+' — '+err.message,msg);}); + if(bodyStr) req.write(bodyStr); + req.end(); + }); + } + function getField(node,name) { + var m={ 'fileId':node.bodyFileId,'expirationTime':node.bodyExpirationTime,'firstDay':node.bodyFirstDay, + 'lastDay':node.bodyLastDay,'status':node.bodyStatus,'message':node.bodyMessage, + 'replacementUserId':node.bodyReplacementUserId,'location':node.bodyLocation }; + return m[name]; + } + RED.nodes.registerType("dav", DavNode); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/file-operations.html b/nodes/nextcloud-ocs/file-operations.html new file mode 100644 index 0000000..e2f0809 --- /dev/null +++ b/nodes/nextcloud-ocs/file-operations.html @@ -0,0 +1,142 @@ + + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/file-operations.js b/nodes/nextcloud-ocs/file-operations.js new file mode 100644 index 0000000..f35db98 --- /dev/null +++ b/nodes/nextcloud-ocs/file-operations.js @@ -0,0 +1,170 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + // Read/List + 'list': { method: 'PROPFIND', path: '/remote.php/dav/files/{user}/{folder}' }, + 'get': { method: 'GET', path: '/remote.php/dav/files/{user}/{path}' }, + // Write + 'upload': { method: 'PUT', path: '/remote.php/dav/files/{user}/{path}' }, + 'mkdir': { method: 'MKCOL', path: '/remote.php/dav/files/{user}/{folder}' }, + // Modify + 'move': { method: 'MOVE', path: '/remote.php/dav/files/{user}/{path}', destHeader: true }, + 'copy': { method: 'COPY', path: '/remote.php/dav/files/{user}/{path}', destHeader: true }, + // Delete + 'delete': { method: 'DELETE', path: '/remote.php/dav/files/{user}/{path}' }, + // OCS sharing + 'listShares': { method: 'GET', path: '/ocs/v2.php/apps/files_sharing/api/v1/shares' }, + 'createShare': { method: 'POST', path: '/ocs/v2.php/apps/files_sharing/api/v1/shares', body: ['path','shareType','permissions','shareWith'] }, + 'deleteShare': { method: 'DELETE', path: '/ocs/v2.php/apps/files_sharing/api/v1/shares/{id}' }, + // File info + 'fileInfo': { method: 'GET', path: '/ocs/v2.php/apps/files/api/v1/stats' }, + 'favorites': { method: 'GET', path: '/ocs/v2.php/apps/files/api/v1/files' } + }; + + function FileOperationsNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'list'; + this.ncUser = config.ncUser || ''; + this.ncPath = config.ncPath || ''; + this.ncFolder = config.ncFolder || ''; + this.destination = config.destination || ''; + this.bodySharePath = config.bodySharePath; + this.bodyShareType = config.bodyShareType; + this.bodyPermissions = config.bodyPermissions; + this.bodyShareWith = config.bodyShareWith; + + if (!this.configNode) { + node.error("No Nextcloud configuration node selected"); + return; + } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { + node.error("Unknown operation: " + (msg.operation || node.operation), msg); + return; + } + + var baseUrl = (node.configNode.baseUrl || '').replace(/\/+$/, ''); + var username = node.configNode.credentials.username; + var password = node.configNode.credentials.password; + + var ncUser = msg.ncUser || node.ncUser || username; + var ncPath = msg.ncPath || node.ncPath || ''; + var ncFolder = msg.ncFolder || node.ncFolder || ''; + var dest = msg.destination || node.destination || ''; + + var path = op.path + .replace('{user}', encodeURIComponent(ncUser)) + .replace('{path}', ncPath) + .replace('{folder}', ncFolder) + .replace('{id}', msg.id || ''); + + var fullUrl = baseUrl + path; + var parsedUrl = url.parse(fullUrl); + + var headers = { + 'Authorization': 'Basic ' + Buffer.from(username + ':' + password).toString('base64') + }; + + // WebDAV requests need depth header for PROPFIND + if (op.method === 'PROPFIND') { + headers['Depth'] = msg.depth || '1'; + headers['Content-Type'] = 'application/xml'; + } + + // MOVE/COPY use Destination header + if (op.destHeader && dest) { + headers['Destination'] = baseUrl + '/remote.php/dav/files/' + encodeURIComponent(ncUser) + '/' + dest; + } + + // OCS sharing headers + if (op.path.indexOf('/ocs/') === 0) { + headers['OCS-APIRequest'] = 'true'; + headers['Accept'] = 'application/json'; + } + + if (msg.headers && typeof msg.headers === 'object') { + Object.keys(msg.headers).forEach(function(k) { + headers[k] = msg.headers[k]; + }); + } + + var bodyStr = null; + if (op.body) { + var bodyObj = {}; + op.body.forEach(function(b) { + var val = msg[b] !== undefined ? msg[b] : getBodyField(node, b); + if (val !== undefined && val !== '') { + bodyObj[b] = val; + } + }); + if (Object.keys(bodyObj).length > 0) { + headers['Content-Type'] = 'application/json'; + bodyStr = JSON.stringify(bodyObj); + } + } + if (msg.body) { + headers['Content-Type'] = msg.contentType || 'application/json'; + bodyStr = typeof msg.body === 'string' ? msg.body : JSON.stringify(msg.body); + } + if (msg.payload && op.method === 'PUT') { + bodyStr = typeof msg.payload === 'string' ? msg.payload : JSON.stringify(msg.payload); + } + + var opts = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), + path: parsedUrl.path, + method: op.method, + headers: headers, + rejectUnauthorized: false + }; + + var transport = parsedUrl.protocol === 'https:' ? https : http; + + node.log(op.method + ' ' + fullUrl); + + var req = transport.request(opts, function(res) { + var body = ''; + res.on('data', function(chunk) { body += chunk; }); + res.on('end', function() { + msg.statusCode = res.statusCode; + msg.operation = msg.operation || node.operation; + // Try JSON, fallback to XML/text + try { msg.payload = JSON.parse(body); } + catch(e) { + // PROPFIND returns XML + msg.payload = body; + } + node.send(msg); + }); + }); + + req.on('error', function(err) { + msg.error = err.message; + node.error(op.method + ' ' + fullUrl + ' — ' + err.message, msg); + }); + + if (bodyStr) { + req.write(bodyStr); + } + req.end(); + }); + } + + function getBodyField(node, name) { + var map = { + 'path': node.bodySharePath, 'shareType': node.bodyShareType, + 'permissions': node.bodyPermissions, 'shareWith': node.bodyShareWith + }; + return map[name]; + } + + RED.nodes.registerType("file-operations", FileOperationsNode); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/filesharing.html b/nodes/nextcloud-ocs/filesharing.html new file mode 100644 index 0000000..eea0e32 --- /dev/null +++ b/nodes/nextcloud-ocs/filesharing.html @@ -0,0 +1,20 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/filesharing.js b/nodes/nextcloud-ocs/filesharing.js new file mode 100644 index 0000000..43011c4 --- /dev/null +++ b/nodes/nextcloud-ocs/filesharing.js @@ -0,0 +1,91 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + // Shares + 'shares:list': { method:'GET', path:'/ocs/v2.php/apps/files_sharing/api/v1/shares', query:['shared_with_me','reshares','subfiles','path','include_tags'] }, + 'shares:get': { method:'GET', path:'/ocs/v2.php/apps/files_sharing/api/v1/shares/{id}', query:['include_tags'] }, + 'shares:create': { method:'POST', path:'/ocs/v2.php/apps/files_sharing/api/v1/shares', body:['path','permissions','shareType','shareWith','password','expireDate','note','label','publicUpload','sendPasswordByTalk','attributes'] }, + 'shares:update': { method:'PUT', path:'/ocs/v2.php/apps/files_sharing/api/v1/shares/{id}', body:['permissions','password','expireDate','note','label','hideDownload','token','sendPasswordByTalk'] }, + 'shares:delete': { method:'DELETE', path:'/ocs/v2.php/apps/files_sharing/api/v1/shares/{id}' }, + 'shares:inherited': { method:'GET', path:'/ocs/v2.php/apps/files_sharing/api/v1/shares/inherited', query:['path'] }, + 'shares:pending': { method:'GET', path:'/ocs/v2.php/apps/files_sharing/api/v1/shares/pending' }, + 'shares:accept': { method:'POST', path:'/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/{id}' }, + 'shares:sendEmail': { method:'POST', path:'/ocs/v2.php/apps/files_sharing/api/v1/shares/{id}/send-email', body:['password'] }, + 'shares:token': { method:'GET', path:'/ocs/v2.php/apps/files_sharing/api/v1/token' }, + // Deleted shares + 'deleted:list': { method:'GET', path:'/ocs/v2.php/apps/files_sharing/api/v1/deletedshares' }, + 'deleted:restore': { method:'POST', path:'/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/{id}' }, + // Sharees + 'sharees:search': { method:'GET', path:'/ocs/v2.php/apps/files_sharing/api/v1/sharees', query:['search','itemType','page','perPage','shareType','lookup'] }, + 'sharees:recommended':{ method:'GET', path:'/ocs/v2.php/apps/files_sharing/api/v1/sharees_recommended', query:['itemType','shareType'] }, + // Remote shares + 'remote:list': { method:'GET', path:'/ocs/v2.php/apps/files_sharing/api/v1/remote_shares' }, + 'remote:pending': { method:'GET', path:'/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending' }, + 'remote:get': { method:'GET', path:'/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/{id}' }, + 'remote:accept': { method:'POST', path:'/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending/{id}' }, + 'remote:decline': { method:'DELETE', path:'/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending/{id}' }, + 'remote:unshare': { method:'DELETE', path:'/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/{id}' }, + // Preview + Info + 'preview:get': { method:'GET', path:'/index.php/apps/files_sharing/publicpreview/{token}', query:['file','x','y','a'] }, + 'info:get': { method:'POST', path:'/index.php/apps/files_sharing/shareinfo', body:['t','password','dir','depth'] } + }; + + function FSNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'shares:list'; + this.shareId = config.shareId; this.token = config.token; + + if (!this.configNode) { node.error("No NC config node"); return; } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { node.error("Unknown: "+(msg.operation||node.operation), msg); return; } + + var baseUrl = (node.configNode.baseUrl||'').replace(/\/+$/,''); + var uname = node.configNode.credentials.username; + var pw = node.configNode.credentials.password; + var path = op.path + .replace(/\{id\}/g, msg.shareId||node.shareId||'') + .replace(/\{token\}/g, msg.token||node.token||''); + + var qp = []; + if (op.query) op.query.forEach(function(q) { + var v = msg[q]!==undefined ? msg[q] : ''; + if (v!==undefined&&v!=='') qp.push(encodeURIComponent(q)+'='+encodeURIComponent(v)); + }); + var fu = baseUrl+path+(qp.length?'?'+qp.join('&'):''); + var pu = url.parse(fu); + + var h = {'Accept':'application/json', + 'Authorization':'Basic '+Buffer.from(uname+':'+pw).toString('base64')}; + if (path.indexOf('/ocs/')===0) h['OCS-APIRequest']='true'; + + var bo={}; + if (op.body) op.body.forEach(function(b){ + var v=msg[b]!==undefined?msg[b]:''; + if(v!==undefined&&v!=='') bo[b]=v; + }); + var bs=null; + if (Object.keys(bo).length>0) {h['Content-Type']='application/json'; bs=JSON.stringify(bo);} + + var o = {hostname:pu.hostname,port:pu.port||(pu.protocol==='https:'?443:80), + path:pu.path,method:op.method,headers:h,rejectUnauthorized:false}; + node.log(op.method+' '+fu); + var t = pu.protocol==='https:'?https:http; + var r = t.request(o,function(res){ + var b='';res.on('data',function(c){b+=c;}); + res.on('end',function(){msg.statusCode=res.statusCode;msg.operation=msg.operation||node.operation; + try{msg.payload=JSON.parse(b);}catch(e){msg.payload=b;}node.send(msg);}); + }); + r.on('error',function(e){msg.error=e.message;node.error(op.method+' '+fu+' - '+e.message,msg);}); + if(bs) r.write(bs); + r.end(); + }); + } + RED.nodes.registerType("filesharing", FSNode); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/mail.html b/nodes/nextcloud-ocs/mail.html new file mode 100644 index 0000000..36e663e --- /dev/null +++ b/nodes/nextcloud-ocs/mail.html @@ -0,0 +1,67 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/mail.js b/nodes/nextcloud-ocs/mail.js new file mode 100644 index 0000000..452730d --- /dev/null +++ b/nodes/nextcloud-ocs/mail.js @@ -0,0 +1,116 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + 'account:list': { method: 'GET', path: '/ocs/v2.php/apps/mail/account/list' }, + 'message:get': { method: 'GET', path: '/ocs/v2.php/apps/mail/message/{id}' }, + 'message:getRaw': { method: 'GET', path: '/ocs/v2.php/apps/mail/message/{id}/raw' }, + 'message:attachment': { method: 'GET', path: '/ocs/v2.php/apps/mail/message/{id}/attachment/{attachmentId}' }, + 'mailbox:list': { method: 'GET', path: '/ocs/v2.php/apps/mail/ocs/mailboxes', query: ['accountId'] }, + 'mailbox:messages': { method: 'GET', path: '/ocs/v2.php/apps/mail/ocs/mailboxes/{mailboxId}/messages', query: ['cursor','filter','limit','view'] }, + 'message:send': { method: 'POST', path: '/ocs/v2.php/apps/mail/message/send', + body: ['accountId','fromEmail','subject','body','isHtml','to','cc','bcc','references'] } + }; + + function MailNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'account:list'; + this.messageId = config.messageId; + this.attachmentId = config.attachmentId; + this.mailboxId = config.mailboxId; + this.bodyAccountId = config.bodyAccountId; + this.bodyFromEmail = config.bodyFromEmail; + this.bodySubject = config.bodySubject; + this.bodyBody = config.bodyBody; + this.bodyIsHtml = config.bodyIsHtml; + this.bodyTo = config.bodyTo; + this.bodyCc = config.bodyCc; + this.bodyBcc = config.bodyBcc; + this.bodyReferences = config.bodyReferences; + + if (!this.configNode) { node.error("No Nextcloud configuration node selected"); return; } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { node.error("Unknown operation: " + (msg.operation || node.operation), msg); return; } + + var baseUrl = (node.configNode.baseUrl || '').replace(/\/+$/, ''); + var username = node.configNode.credentials.username; + var password = node.configNode.credentials.password; + + var path = op.path + .replace('{id}', msg.messageId || node.messageId || '') + .replace('{attachmentId}', msg.attachmentId || node.attachmentId || '') + .replace('{mailboxId}', msg.mailboxId || node.mailboxId || ''); + + var queryParts = []; + if (op.query) { + op.query.forEach(function(q) { + var v = msg[q] !== undefined ? msg[q] : getField(node, q); + if (v !== undefined && v !== '') queryParts.push(encodeURIComponent(q) + '=' + encodeURIComponent(v)); + }); + } + var fullUrl = baseUrl + path + (queryParts.length ? '?' + queryParts.join('&') : ''); + var parsedUrl = url.parse(fullUrl); + + var headers = { + 'OCS-APIRequest': 'true', 'Accept': 'application/json', + 'Authorization': 'Basic ' + Buffer.from(username + ':' + password).toString('base64') + }; + + var bodyObj = {}; + if (op.body) { + op.body.forEach(function(b) { + var v = msg[b] !== undefined ? msg[b] : getField(node, b); + if (v !== undefined && v !== '') bodyObj[b] = v; + }); + } + // Handle complex send fields: to/cc/bcc can be msg arrays + if (msg.to && Array.isArray(msg.to)) bodyObj.to = msg.to; + if (msg.cc && Array.isArray(msg.cc)) bodyObj.cc = msg.cc; + if (msg.bcc && Array.isArray(msg.bcc)) bodyObj.bcc = msg.bcc; + + var bodyStr = null; + if (Object.keys(bodyObj).length > 0) { + headers['Content-Type'] = 'application/json'; + bodyStr = JSON.stringify(bodyObj); + } + + var opts = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), + path: parsedUrl.path, method: op.method, headers: headers, rejectUnauthorized: false + }; + + node.log(op.method + ' ' + fullUrl); + + var transport = parsedUrl.protocol === 'https:' ? https : http; + var req = transport.request(opts, function(res) { + var body = ''; + res.on('data', function(chunk) { body += chunk; }); + res.on('end', function() { + msg.statusCode = res.statusCode; msg.operation = msg.operation || node.operation; + try { msg.payload = JSON.parse(body); } catch(e) { msg.payload = body; } + node.send(msg); + }); + }); + req.on('error', function(err) { msg.error = err.message; node.error(op.method + ' ' + fullUrl + ' — ' + err.message, msg); }); + if (bodyStr) req.write(bodyStr); + req.end(); + }); + } + + function getField(node, name) { + var m = { 'accountId': node.bodyAccountId, 'fromEmail': node.bodyFromEmail, + 'subject': node.bodySubject, 'body': node.bodyBody, 'isHtml': node.bodyIsHtml, + 'references': node.bodyReferences, 'cursor': node.bodyCursor, 'filter': node.bodyFilter, + 'limit': node.bodyLimit, 'view': node.bodyView }; + return m[name]; + } + + RED.nodes.registerType("mail", MailNode); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/nextcloud-config.html b/nodes/nextcloud-ocs/nextcloud-config.html new file mode 100644 index 0000000..c05589e --- /dev/null +++ b/nodes/nextcloud-ocs/nextcloud-config.html @@ -0,0 +1,37 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/nextcloud-config.js b/nodes/nextcloud-ocs/nextcloud-config.js new file mode 100644 index 0000000..30a94ce --- /dev/null +++ b/nodes/nextcloud-ocs/nextcloud-config.js @@ -0,0 +1,15 @@ +module.exports = function(RED) { + function NextcloudConfigNode(config) { + RED.nodes.createNode(this, config); + this.name = config.name; + this.baseUrl = config.baseUrl; + this.username = config.username; + this.password = config.password; + } + RED.nodes.registerType("nextcloud-config", NextcloudConfigNode, { + credentials: { + username: { type: "text" }, + password: { type: "password" } + } + }); +} \ No newline at end of file diff --git a/nodes/nextcloud-ocs/oauth2.html b/nodes/nextcloud-ocs/oauth2.html new file mode 100644 index 0000000..68d3a03 --- /dev/null +++ b/nodes/nextcloud-ocs/oauth2.html @@ -0,0 +1,14 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/oauth2.js b/nodes/nextcloud-ocs/oauth2.js new file mode 100644 index 0000000..7292ab2 --- /dev/null +++ b/nodes/nextcloud-ocs/oauth2.js @@ -0,0 +1,62 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + 'oauth:authorize': { method:'GET', path:'/index.php/apps/oauth2/authorize', query:['client_id','state','response_type','redirect_uri'] }, + 'oauth:token': { method:'POST', path:'/index.php/apps/oauth2/api/v1/token', body:['grant_type','code','refresh_token','client_id','client_secret'] } + }; + + function OAuth2Node(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'oauth:token'; + + if (!this.configNode) { node.error("No NC config node"); return; } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { node.error("Unknown: "+(msg.operation||node.operation), msg); return; } + + var baseUrl = (node.configNode.baseUrl||'').replace(/\/+$/,''); + var uname = node.configNode.credentials.username; + var pw = node.configNode.credentials.password; + var path = op.path; + + var qp = []; + if (op.query) op.query.forEach(function(q) { + var v = msg[q]!==undefined ? msg[q] : ''; + if (v!==undefined&&v!=='') qp.push(encodeURIComponent(q)+'='+encodeURIComponent(v)); + }); + var fu = baseUrl+path+(qp.length?'?'+qp.join('&'):''); + var pu = url.parse(fu); + + var h = {'Accept':'application/json', + 'Authorization':'Basic '+Buffer.from(uname+':'+pw).toString('base64')}; + + var bo={}; + if (op.body) op.body.forEach(function(b){ + var v=msg[b]!==undefined?msg[b]:''; + if(v!==undefined&&v!=='') bo[b]=v; + }); + var bs=null; + if (Object.keys(bo).length>0) {h['Content-Type']='application/json'; bs=JSON.stringify(bo);} + + var o = {hostname:pu.hostname,port:pu.port||(pu.protocol==='https:'?443:80), + path:pu.path,method:op.method,headers:h,rejectUnauthorized:false}; + node.log(op.method+' '+fu); + var t = pu.protocol==='https:'?https:http; + var r = t.request(o,function(res){ + var b='';res.on('data',function(c){b+=c;}); + res.on('end',function(){msg.statusCode=res.statusCode;msg.operation=msg.operation||node.operation; + try{msg.payload=JSON.parse(b);}catch(e){msg.payload=b;}node.send(msg);}); + }); + r.on('error',function(e){msg.error=e.message;node.error(op.method+' '+fu+' - '+e.message,msg);}); + if(bs) r.write(bs); + r.end(); + }); + } + RED.nodes.registerType("oauth2", OAuth2Node); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/ocs-api.html b/nodes/nextcloud-ocs/ocs-api.html new file mode 100644 index 0000000..81c2b9a --- /dev/null +++ b/nodes/nextcloud-ocs/ocs-api.html @@ -0,0 +1,44 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/ocs-api.js b/nodes/nextcloud-ocs/ocs-api.js new file mode 100644 index 0000000..028a6d4 --- /dev/null +++ b/nodes/nextcloud-ocs/ocs-api.js @@ -0,0 +1,84 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + function OcsApiNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.endpoint = config.endpoint || '/ocs/v2.php/cloud/user'; + this.method = config.method || 'GET'; + + if (!this.configNode) { + node.error("No Nextcloud configuration node selected"); + return; + } + + node.on('input', function(msg) { + var baseUrl = node.configNode.baseUrl; + var username = node.configNode.credentials.username; + var password = node.configNode.credentials.password; + + // Allow msg to override endpoint + var apiPath = msg.endpoint || node.endpoint; + var apiMethod = msg.method || node.method; + + // Build full URL + var fullUrl = baseUrl.replace(/\/+$/, '') + '/' + apiPath.replace(/^\/+/, ''); + var parsedUrl = url.parse(fullUrl); + + // Add OCS headers + var headers = { + 'OCS-APIRequest': 'true', + 'Accept': 'application/json', + 'Authorization': 'Basic ' + Buffer.from(username + ':' + password).toString('base64') + }; + + if (msg.headers && typeof msg.headers === 'object') { + Object.keys(msg.headers).forEach(function(k) { + headers[k] = msg.headers[k]; + }); + } + + var opts = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), + path: parsedUrl.path, + method: apiMethod, + headers: headers, + rejectUnauthorized: false // Allow self-signed certs + }; + + var transport = parsedUrl.protocol === 'https:' ? https : http; + + node.log(apiMethod + ' ' + fullUrl); + + var req = transport.request(opts, function(res) { + var body = ''; + res.on('data', function(chunk) { body += chunk; }); + res.on('end', function() { + msg.statusCode = res.statusCode; + try { + msg.payload = JSON.parse(body); + } catch(e) { + msg.payload = body; + } + node.send(msg); + }); + }); + + req.on('error', function(err) { + msg.error = err.message; + node.error(apiMethod + ' ' + fullUrl + ' — ' + err.message, msg); + }); + + if (msg.body) { + req.write(typeof msg.body === 'string' ? msg.body : JSON.stringify(msg.body)); + } + + req.end(); + }); + } + RED.nodes.registerType("ocs-api", OcsApiNode); +} \ No newline at end of file diff --git a/nodes/nextcloud-ocs/package.json b/nodes/nextcloud-ocs/package.json new file mode 100644 index 0000000..360c762 --- /dev/null +++ b/nodes/nextcloud-ocs/package.json @@ -0,0 +1,26 @@ +{ + "name": "node-red-contrib-nextcloud-ocs", + "version": "0.8.0", + "description": "Node-RED nodes for Nextcloud — 16 nodes covering full OCS API surface", + "keywords": ["node-red", "nextcloud", "ocs", "api"], + "node-red": { + "nodes": { + "nextcloud-config": "nextcloud-config.js", + "ocs-api": "ocs-api.js", + "collectives": "collectives.js", + "file-operations": "file-operations.js", + "mail": "mail.js", + "tables": "tables.js", + "talk": "talk.js", + "webhooks": "webhooks.js", + "dashboard": "dashboard.js", + "dav": "dav.js", + "core": "core.js", + "oauth2": "oauth2.js", + "provisioning": "provisioning.js", + "filesharing": "filesharing.js", + "userstatus": "userstatus.js", + "settings": "settings.js" + } + } +} \ No newline at end of file diff --git a/nodes/nextcloud-ocs/provisioning.html b/nodes/nextcloud-ocs/provisioning.html new file mode 100644 index 0000000..a668575 --- /dev/null +++ b/nodes/nextcloud-ocs/provisioning.html @@ -0,0 +1,24 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/provisioning.js b/nodes/nextcloud-ocs/provisioning.js new file mode 100644 index 0000000..1359985 --- /dev/null +++ b/nodes/nextcloud-ocs/provisioning.js @@ -0,0 +1,116 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + // Groups + 'groups:list': { method:'GET', path:'/ocs/v2.php/cloud/groups', query:['search','limit','offset'] }, + 'groups:details': { method:'GET', path:'/ocs/v2.php/cloud/groups/details', query:['search','limit','offset'] }, + 'groups:get': { method:'GET', path:'/ocs/v2.php/cloud/groups/{groupId}' }, + 'groups:users': { method:'GET', path:'/ocs/v2.php/cloud/groups/{groupId}/users' }, + 'groups:usersDetails':{ method:'GET', path:'/ocs/v2.php/cloud/groups/{groupId}/users/details', query:['search','limit','offset'] }, + // Users + 'users:list': { method:'GET', path:'/ocs/v2.php/cloud/users', query:['search','limit','offset'] }, + 'users:details': { method:'GET', path:'/ocs/v2.php/cloud/users/details', query:['search','limit','offset'] }, + 'users:disabled': { method:'GET', path:'/ocs/v2.php/cloud/users/disabled', query:['search','limit','offset'] }, + 'users:create': { method:'POST', path:'/ocs/v2.php/cloud/users', body:['userid','password','displayName','email','quota','language'] }, + 'users:get': { method:'GET', path:'/ocs/v2.php/cloud/users/{userId}' }, + 'users:edit': { method:'PUT', path:'/ocs/v2.php/cloud/users/{userId}', body:['key','value'] }, + 'users:editMulti': { method:'PUT', path:'/ocs/v2.php/cloud/users/{userId}/{collectionName}', body:['key','value'] }, + 'users:delete': { method:'DELETE', path:'/ocs/v2.php/cloud/users/{userId}' }, + 'users:enable': { method:'PUT', path:'/ocs/v2.php/cloud/users/{userId}/enable' }, + 'users:disable': { method:'PUT', path:'/ocs/v2.php/cloud/users/{userId}/disable' }, + 'users:wipe': { method:'POST', path:'/ocs/v2.php/cloud/users/{userId}/wipe' }, + 'users:current': { method:'GET', path:'/ocs/v2.php/cloud/user' }, + 'users:editableFields':{ method:'GET', path:'/ocs/v2.php/cloud/user/fields' }, + 'users:editableFor': { method:'GET', path:'/ocs/v2.php/cloud/user/fields/{userId}' }, + 'users:apps': { method:'GET', path:'/ocs/v2.php/cloud/user/apps' }, + 'users:groups': { method:'GET', path:'/ocs/v2.php/cloud/users/{userId}/groups' }, + 'users:groupsDetails':{ method:'GET', path:'/ocs/v2.php/cloud/users/{userId}/groups/details' }, + 'users:subadminDetails':{ method:'GET', path:'/ocs/v2.php/cloud/users/{userId}/subadmins/details' }, + 'users:addToGroup': { method:'POST', path:'/ocs/v2.php/cloud/users/{userId}/groups', body:['groupid'] }, + 'users:removeFromGroup':{ method:'DELETE', path:'/ocs/v2.php/cloud/users/{userId}/groups', query:['groupid'] }, + 'users:welcome': { method:'POST', path:'/ocs/v2.php/cloud/users/{userId}/welcome' }, + 'users:searchByPhone':{ method:'POST', path:'/ocs/v2.php/cloud/users/search/by-phone', body:['location','search'] }, + // App config + 'config:setApp': { method:'POST', path:'/ocs/v2.php/apps/provisioning_api/api/v1/config/apps/{app}/{key}', body:['value'] }, + // Preferences + 'pref:set': { method:'POST', path:'/ocs/v2.php/apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', body:['configValue'] }, + 'pref:delete': { method:'DELETE', path:'/ocs/v2.php/apps/provisioning_api/api/v1/config/users/{appId}/{configKey}' }, + 'pref:setMultiple': { method:'POST', path:'/ocs/v2.php/apps/provisioning_api/api/v1/config/users/{appId}', body:['configs'] }, + 'pref:deleteMultiple':{ method:'DELETE', path:'/ocs/v2.php/apps/provisioning_api/api/v1/config/users/{appId}', query:['configKeys[]'] } + }; + + function ProvisioningNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'users:list'; + this.userId = config.userId; this.groupId = config.groupId; + this.appId = config.appId; this.configKey = config.configKey; + this.collectionName = config.collectionName; this.app = config.app; this.key = config.key; + + if (!this.configNode) { node.error("No NC config node"); return; } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { node.error("Unknown: "+(msg.operation||node.operation), msg); return; } + + var baseUrl = (node.configNode.baseUrl||'').replace(/\/+$/,''); + var uname = node.configNode.credentials.username; + var pw = node.configNode.credentials.password; + + var path = op.path + .replace(/\{userId\}/g, msg.userId||node.userId||'') + .replace(/\{groupId\}/g, msg.groupId||node.groupId||'') + .replace(/\{appId\}/g, msg.appId||node.appId||'') + .replace(/\{configKey\}/g, msg.configKey||node.configKey||'') + .replace(/\{collectionName\}/g, msg.collectionName||node.collectionName||'') + .replace(/\{app\}/g, msg.app||node.app||'') + .replace(/\{key\}/g, msg.key||node.key||''); + + var qp = []; + if (op.query) op.query.forEach(function(q) { + var v = msg[q]!==undefined ? msg[q] : getF(node,q); + if (v!==undefined&&v!=='') qp.push(encodeURIComponent(q)+'='+encodeURIComponent(v)); + }); + var fu = baseUrl+path+(qp.length?'?'+qp.join('&'):''); + var pu = url.parse(fu); + + var h = {'OCS-APIRequest':'true','Accept':'application/json', + 'Authorization':'Basic '+Buffer.from(uname+':'+pw).toString('base64')}; + + var bo={}; + if (op.body) op.body.forEach(function(b){ + var v=msg[b]!==undefined?msg[b]:getF(node,b); + if(v!==undefined&&v!=='') bo[b]=v; + }); + // Special: groups array for user creation, configs object + if (msg.groups && Array.isArray(msg.groups)) bo.groups = msg.groups; + if (msg.subadmin && Array.isArray(msg.subadmin)) bo.subadmin = msg.subadmin; + if (msg.configs && typeof msg.configs==='object') bo.configs = msg.configs; + + var bs=null; + if (Object.keys(bo).length>0) {h['Content-Type']='application/json'; bs=JSON.stringify(bo);} + + var o = {hostname:pu.hostname,port:pu.port||(pu.protocol==='https:'?443:80), + path:pu.path,method:op.method,headers:h,rejectUnauthorized:false}; + node.log(op.method+' '+fu); + var t = pu.protocol==='https:'?https:http; + var r = t.request(o,function(res){ + var b='';res.on('data',function(c){b+=c;}); + res.on('end',function(){msg.statusCode=res.statusCode;msg.operation=msg.operation||node.operation; + try{msg.payload=JSON.parse(b);}catch(e){msg.payload=b;}node.send(msg);}); + }); + r.on('error',function(e){msg.error=e.message;node.error(op.method+' '+fu+' - '+e.message,msg);}); + if(bs) r.write(bs); + r.end(); + }); + } + function getF(node,n){var m={'search':node.bodySearch,'limit':node.bodyLimit,'offset':node.bodyOffset,'userid':node.bodyUserid, + 'password':node.bodyPassword,'displayName':node.bodyDisplayName,'email':node.bodyEmail,'quota':node.bodyQuota, + 'language':node.bodyLanguage,'key':node.bodyKey,'value':node.bodyValue,'groupid':node.bodyGroupid, + 'location':node.bodyLocation,'configValue':node.bodyConfigValue};return m[n];} + RED.nodes.registerType("provisioning", ProvisioningNode); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/settings.html b/nodes/nextcloud-ocs/settings.html new file mode 100644 index 0000000..0ee42e4 --- /dev/null +++ b/nodes/nextcloud-ocs/settings.html @@ -0,0 +1,15 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/settings.js b/nodes/nextcloud-ocs/settings.js new file mode 100644 index 0000000..adef55c --- /dev/null +++ b/nodes/nextcloud-ocs/settings.js @@ -0,0 +1,58 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + 'log:download': { method:'GET', path:'/index.php/settings/admin/log/download' }, + 'forms:list': { method:'GET', path:'/ocs/v2.php/settings/api/declarative/forms' }, + 'forms:setValue': { method:'POST', path:'/ocs/v2.php/settings/api/declarative/value', body:['app','formId','fieldId','value'] }, + 'forms:setSensitive': { method:'POST', path:'/ocs/v2.php/settings/api/declarative/value-sensitive', body:['app','formId','fieldId','value'] } + }; + + function SettingsNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'forms:list'; + + if (!this.configNode) { node.error("No NC config node"); return; } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { node.error("Unknown: "+(msg.operation||node.operation), msg); return; } + + var baseUrl = (node.configNode.baseUrl||'').replace(/\/+$/,''); + var uname = node.configNode.credentials.username; + var pw = node.configNode.credentials.password; + var fu = baseUrl+op.path; + var pu = url.parse(fu); + + var h = {'Accept':'application/json', + 'Authorization':'Basic '+Buffer.from(uname+':'+pw).toString('base64')}; + if (op.path.indexOf('/ocs/')===0) h['OCS-APIRequest']='true'; + + var bo={}; + if (op.body) op.body.forEach(function(b){ + var v=msg[b]!==undefined?msg[b]:''; + if(v!==undefined&&v!=='') bo[b]=v; + }); + var bs=null; + if (Object.keys(bo).length>0) {h['Content-Type']='application/json'; bs=JSON.stringify(bo);} + + var o = {hostname:pu.hostname,port:pu.port||(pu.protocol==='https:'?443:80), + path:pu.path,method:op.method,headers:h,rejectUnauthorized:false}; + node.log(op.method+' '+fu); + var t = pu.protocol==='https:'?https:http; + var r = t.request(o,function(res){ + var b='';res.on('data',function(c){b+=c;}); + res.on('end',function(){msg.statusCode=res.statusCode;msg.operation=msg.operation||node.operation; + try{msg.payload=JSON.parse(b);}catch(e){msg.payload=b;}node.send(msg);}); + }); + r.on('error',function(e){msg.error=e.message;node.error(op.method+' '+fu+' - '+e.message,msg);}); + if(bs) r.write(bs); + r.end(); + }); + } + RED.nodes.registerType("settings", SettingsNode); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/tables.html b/nodes/nextcloud-ocs/tables.html new file mode 100644 index 0000000..26dfdc8 --- /dev/null +++ b/nodes/nextcloud-ocs/tables.html @@ -0,0 +1,48 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/tables.js b/nodes/nextcloud-ocs/tables.js new file mode 100644 index 0000000..cddbf07 --- /dev/null +++ b/nodes/nextcloud-ocs/tables.js @@ -0,0 +1,162 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + // Tables (api v2 OCS) + 'table:list': { method:'GET', path:'/ocs/v2.php/apps/tables/api/2/tables' }, + 'table:get': { method:'GET', path:'/ocs/v2.php/apps/tables/api/2/tables/{id}' }, + 'table:create': { method:'POST', path:'/ocs/v2.php/apps/tables/api/2/tables', body:['title','emoji','description','template'] }, + 'table:update': { method:'PUT', path:'/ocs/v2.php/apps/tables/api/2/tables/{id}', body:['title','emoji','description','archived'] }, + 'table:delete': { method:'DELETE', path:'/ocs/v2.php/apps/tables/api/2/tables/{id}' }, + 'table:scheme': { method:'GET', path:'/ocs/v2.php/apps/tables/api/2/tables/scheme/{id}' }, + 'table:transfer': { method:'PUT', path:'/ocs/v2.php/apps/tables/api/2/tables/{id}/transfer', body:['newOwnerUserId'] }, + // Init + 'init:index': { method:'GET', path:'/ocs/v2.php/apps/tables/api/2/init' }, + // Columns (api v2) + 'column:list': { method:'GET', path:'/ocs/v2.php/apps/tables/api/2/columns/{nodeType}/{nodeId}' }, + 'column:get': { method:'GET', path:'/ocs/v2.php/apps/tables/api/2/columns/{id}' }, + 'column:text': { method:'POST', path:'/ocs/v2.php/apps/tables/api/2/columns/text', body:['baseNodeId','title','textDefault','textMaxLength','textUnique','mandatory','baseNodeType','selectedViewIds'] }, + 'column:number': { method:'POST', path:'/ocs/v2.php/apps/tables/api/2/columns/number', body:['baseNodeId','title','numberDefault','numberDecimals','numberMin','numberMax','mandatory','baseNodeType'] }, + 'column:selection': { method:'POST', path:'/ocs/v2.php/apps/tables/api/2/columns/selection', body:['baseNodeId','title','selectionOptions','selectionDefault','mandatory','baseNodeType'] }, + 'column:datetime': { method:'POST', path:'/ocs/v2.php/apps/tables/api/2/columns/datetime', body:['baseNodeId','title','datetimeDefault','mandatory','baseNodeType'] }, + 'column:usergroup': { method:'POST', path:'/ocs/v2.php/apps/tables/api/2/columns/usergroup', body:['baseNodeId','title','usergroupDefault','usergroupMultipleItems','usergroupSelectUsers','usergroupSelectGroups','mandatory','baseNodeType'] }, + // Rows (api v2) + 'row:list': { method:'GET', path:'/ocs/v2.php/apps/tables/api/2/{nodeCollection}/{nodeId}/rows', query:['limit','offset'] }, + 'row:create': { method:'POST', path:'/ocs/v2.php/apps/tables/api/2/{nodeCollection}/{nodeId}/rows', body:['data'] }, + // Generic row ops (api v1) + 'row:get': { method:'GET', path:'/index.php/apps/tables/api/1/rows/{rowId}' }, + 'row:update': { method:'PUT', path:'/index.php/apps/tables/api/1/rows/{rowId}', body:['data'] }, + 'row:delete': { method:'DELETE', path:'/index.php/apps/tables/api/1/rows/{rowId}' }, + // Views + 'view:list': { method:'GET', path:'/index.php/apps/tables/api/1/tables/{tableId}/views' }, + 'view:get': { method:'GET', path:'/index.php/apps/tables/api/1/views/{viewId}' }, + 'view:create': { method:'POST', path:'/index.php/apps/tables/api/1/tables/{tableId}/views', body:['title','emoji'] }, + 'view:update': { method:'PUT', path:'/index.php/apps/tables/api/1/views/{viewId}', body:['data'] }, + 'view:delete': { method:'DELETE', path:'/index.php/apps/tables/api/1/views/{viewId}' }, + // Shares + 'share:listTable': { method:'GET', path:'/index.php/apps/tables/api/1/tables/{tableId}/shares' }, + 'share:listView': { method:'GET', path:'/index.php/apps/tables/api/1/views/{viewId}/shares' }, + 'share:create': { method:'POST', path:'/index.php/apps/tables/api/1/shares', body:['nodeId','nodeType','receiver','receiverType','permissionRead','permissionCreate','permissionUpdate','permissionDelete','permissionManage'] }, + 'share:update': { method:'PUT', path:'/index.php/apps/tables/api/1/shares/{shareId}', body:['permissionType','permissionValue'] }, + 'share:delete': { method:'DELETE', path:'/index.php/apps/tables/api/1/shares/{shareId}' }, + 'share:linkCreate': { method:'POST', path:'/ocs/v2.php/apps/tables/api/2/{nodeCollection}/{nodeId}/share', body:['password'] }, + // Contexts + 'context:list': { method:'GET', path:'/ocs/v2.php/apps/tables/api/2/contexts' }, + 'context:get': { method:'GET', path:'/ocs/v2.php/apps/tables/api/2/contexts/{contextId}' }, + 'context:create': { method:'POST', path:'/ocs/v2.php/apps/tables/api/2/contexts', body:['name','iconName','description'] }, + 'context:update': { method:'PUT', path:'/ocs/v2.php/apps/tables/api/2/contexts/{contextId}', body:['name','iconName','description'] }, + 'context:delete': { method:'DELETE', path:'/ocs/v2.php/apps/tables/api/2/contexts/{contextId}' }, + // Favorites + 'favorite:add': { method:'POST', path:'/ocs/v2.php/apps/tables/api/2/favorites/{nodeType}/{nodeId}' }, + 'favorite:remove': { method:'DELETE', path:'/ocs/v2.php/apps/tables/api/2/favorites/{nodeType}/{nodeId}' }, + // Import + 'import:table': { method:'POST', path:'/index.php/apps/tables/api/1/import/table/{tableId}', body:['path','createMissingColumns'] }, + // Public rows + 'public:rows': { method:'GET', path:'/ocs/v2.php/apps/tables/api/2/public/{token}/rows', query:['limit','offset'] }, + 'public:columns': { method:'GET', path:'/ocs/v2.php/apps/tables/api/2/public/{token}/columns' } + }; + + function TablesNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'table:list'; + this.tableId = config.tableId; this.viewId = config.viewId; this.rowId = config.rowId; + this.shareId = config.shareId; this.nodeId = config.nodeId; this.nodeType = config.nodeType; + this.nodeCollection = config.nodeCollection; this.token = config.token; this.contextId = config.contextId; + this.bodyTitle = config.bodyTitle; this.bodyEmoji = config.bodyEmoji; this.bodyData = config.bodyData; + this.bodyName = config.bodyName; this.bodyReceiver = config.bodyReceiver; this.bodyReceiverType = config.bodyReceiverType; + this.bodyBaseNodeId = config.bodyBaseNodeId; this.bodyBaseNodeType = config.bodyBaseNodeType; + if (!this.configNode) { node.error("No Nextcloud configuration node selected"); return; } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { node.error("Unknown operation: " + (msg.operation || node.operation), msg); return; } + + var baseUrl = (node.configNode.baseUrl || '').replace(/\/+$/, ''); + var username = node.configNode.credentials.username; + var password = node.configNode.credentials.password; + + var path = op.path + .replace(/\{id\}/g, msg.id || node.nodeId || '') + .replace(/\{tableId\}/g, msg.tableId || node.tableId || '') + .replace(/\{viewId\}/g, msg.viewId || node.viewId || '') + .replace(/\{rowId\}/g, msg.rowId || node.rowId || '') + .replace(/\{shareId\}/g, msg.shareId || node.shareId || '') + .replace(/\{nodeId\}/g, msg.nodeId || node.nodeId || '') + .replace(/\{nodeType\}/g, msg.nodeType || node.nodeType || 'table') + .replace(/\{nodeCollection\}/g, msg.nodeCollection || node.nodeCollection || 'tables') + .replace(/\{token\}/g, msg.token || node.token || '') + .replace(/\{contextId\}/g, msg.contextId || node.contextId || ''); + + var queryParts = []; + if (op.query) { + op.query.forEach(function(q) { + var v = msg[q] !== undefined ? msg[q] : getField(node, q); + if (v !== undefined && v !== '') queryParts.push(encodeURIComponent(q) + '=' + encodeURIComponent(v)); + }); + } + var fullUrl = baseUrl + path + (queryParts.length ? '?' + queryParts.join('&') : ''); + var parsedUrl = url.parse(fullUrl); + + var headers = { + 'Accept': 'application/json', + 'Authorization': 'Basic ' + Buffer.from(username + ':' + password).toString('base64') + }; + if (path.indexOf('/ocs/') === 0) headers['OCS-APIRequest'] = 'true'; + + var bodyObj = {}; + if (op.body) { + op.body.forEach(function(b) { + var v = msg[b] !== undefined ? msg[b] : getField(node, b); + if (v !== undefined && v !== '') bodyObj[b] = v; + }); + } + if (msg.data !== undefined) bodyObj.data = msg.data; + if (msg.body && typeof msg.body === 'object') Object.assign(bodyObj, msg.body); + + var bodyStr = null; + if (Object.keys(bodyObj).length > 0) { + headers['Content-Type'] = 'application/json'; + bodyStr = JSON.stringify(bodyObj); + } + + var opts = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), + path: parsedUrl.path, method: op.method, headers: headers, rejectUnauthorized: false + }; + + node.log(op.method + ' ' + fullUrl); + + var transport = parsedUrl.protocol === 'https:' ? https : http; + var req = transport.request(opts, function(res) { + var body = ''; + res.on('data', function(chunk) { body += chunk; }); + res.on('end', function() { + msg.statusCode = res.statusCode; msg.operation = msg.operation || node.operation; + try { msg.payload = JSON.parse(body); } catch(e) { msg.payload = body; } + node.send(msg); + }); + }); + req.on('error', function(err) { msg.error = err.message; node.error(op.method + ' ' + fullUrl + ' — ' + err.message, msg); }); + if (bodyStr) req.write(bodyStr); + req.end(); + }); + } + + function getField(node, name) { + var m = { 'title': node.bodyTitle, 'emoji': node.bodyEmoji, 'name': node.bodyName, + 'receiver': node.bodyReceiver, 'receiverType': node.bodyReceiverType, + 'baseNodeId': node.bodyBaseNodeId, 'baseNodeType': node.bodyBaseNodeType, + 'data': node.bodyData, 'limit': node.bodyLimit, 'offset': node.bodyOffset, + 'password': node.bodyPassword, 'description': node.bodyDescription, + 'template': node.bodyTemplate, 'nodeId': node.nodeId, 'nodeType': node.nodeType, + 'newOwnerUserId': node.bodyNewOwnerId }; + return m[name]; + } + + RED.nodes.registerType("tables", TablesNode); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/talk.html b/nodes/nextcloud-ocs/talk.html new file mode 100644 index 0000000..67484dd --- /dev/null +++ b/nodes/nextcloud-ocs/talk.html @@ -0,0 +1,106 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/talk.js b/nodes/nextcloud-ocs/talk.js new file mode 100644 index 0000000..37ca600 --- /dev/null +++ b/nodes/nextcloud-ocs/talk.js @@ -0,0 +1,234 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + // --- Room --- + 'room:list': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v4/room', query:['noStatusUpdate','includeStatus','modifiedSince','includeLastMessage'] }, + 'room:listed': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v4/listed-room', query:['searchTerm'] }, + 'room:get': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}' }, + 'room:create': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room', body:['roomType','roomName','objectType','objectId','password','readOnly','listable','description','emoji'] }, + 'room:rename': { method:'PUT', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}', body:['roomName'] }, + 'room:delete': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}' }, + 'room:noteToSelf': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v4/room/note-to-self' }, + 'room:description': { method:'PUT', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/description', body:['description'] }, + 'room:password': { method:'PUT', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/password', body:['password'] }, + 'room:readOnly': { method:'PUT', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/read-only', body:['state'] }, + 'room:listable': { method:'PUT', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/listable', body:['scope'] }, + 'room:archive': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/archive' }, + 'room:unarchive': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/archive' }, + 'room:important': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/important' }, + 'room:unimportant': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/important' }, + 'room:sensitive': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/sensitive' }, + 'room:insensitive': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/sensitive' }, + 'room:messageExpiration': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/message-expiration', body:['seconds'] }, + 'room:capabilities': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/capabilities' }, + 'room:scheduleMeeting': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/meeting', body:['calendarUri','start','end','title','description'] }, + // --- Favorites --- + 'favorite:add': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/favorite' }, + 'favorite:remove': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/favorite' }, + // --- Notification --- + 'notify:level': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/notify', body:['level'] }, + 'notify:calls': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/notify-calls', body:['level'] }, + // --- Public/Private --- + 'room:public': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/public', body:['password'] }, + 'room:private': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/public' }, + // --- Participants --- + 'participant:list': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/participants', query:['includeStatus'] }, + 'participant:add': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/participants', body:['newParticipant','source'] }, + 'participant:removeSelf': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/participants/self' }, + 'participant:remove': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/attendees', query:['attendeeId'] }, + 'participant:join': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/participants/active', body:['password','force'] }, + 'participant:leave': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/participants/active' }, + 'participant:sessionState': { method:'PUT', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/participants/state', body:['state'] }, + 'participant:promote': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/moderators', body:['attendeeId'] }, + 'participant:demote': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/moderators', query:['attendeeId'] }, + // --- Permissions --- + 'permissions:default': { method:'PUT', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/permissions/default', body:['permissions'] }, + 'permissions:call': { method:'PUT', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/permissions/call', body:['permissions'] }, + 'permissions:attendee': { method:'PUT', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/attendees/permissions', body:['attendeeId','method','permissions'] }, + // --- Chat --- + 'chat:send': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}', body:['message','replyTo','silent','threadTitle','threadId'] }, + 'chat:receive': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}', query:['lookIntoFuture','limit','lastKnownMessageId','timeout','setReadMarker','threadId'] }, + 'chat:delete': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/{messageId}' }, + 'chat:edit': { method:'PUT', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/{messageId}', body:['message'] }, + 'chat:context': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/{messageId}/context', query:['limit','threadId'] }, + 'chat:reminderSet': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/{messageId}/reminder', body:['timestamp'] }, + 'chat:reminderGet': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/{messageId}/reminder' }, + 'chat:reminderDelete': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/{messageId}/reminder' }, + 'chat:readMarker': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/read', body:['lastReadMessage'] }, + 'chat:unread': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/read' }, + 'chat:pin': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/{messageId}/pin', body:['pinUntil'] }, + 'chat:unpin': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/{messageId}/pin' }, + 'chat:clearHistory': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}' }, + 'chat:share': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/share', body:['objectType','objectId','metaData'] }, + 'chat:sharedOverview': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/share/overview', query:['limit'] }, + 'chat:sharedObjects': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/share', query:['objectType','limit','lastKnownMessageId'] }, + 'chat:mentions': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/mentions', query:['search','limit','includeStatus'] }, + 'chat:schedule': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/schedule', body:['message','sendAt','replyTo','silent'] }, + 'chat:scheduledGet': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/schedule' }, + 'chat:scheduledDelete': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/schedule/{messageId}' }, + // --- Call --- + 'call:peers': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v4/call/{token}' }, + 'call:join': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/call/{token}', body:['flags','silent','recordingConsent'] }, + 'call:updateFlags': { method:'PUT', path:'/ocs/v2.php/apps/spreed/api/v4/call/{token}', body:['flags'] }, + 'call:leave': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v4/call/{token}', query:['all'] }, + 'call:ring': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v4/call/{token}/ring/{attendeeId}' }, + // --- Reaction --- + 'reaction:add': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{messageId}', body:['reaction'] }, + 'reaction:delete': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{messageId}', query:['reaction'] }, + 'reaction:list': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/reaction/{token}/{messageId}', query:['reaction'] }, + // --- Poll --- + 'poll:create': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/poll/{token}', body:['question','options','resultMode','maxVotes','draft','threadId'] }, + 'poll:get': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{pollId}' }, + 'poll:vote': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{pollId}', body:['optionIds'] }, + 'poll:close': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/poll/{token}/{pollId}' }, + // --- Ban --- + 'ban:list': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/ban/{token}' }, + 'ban:add': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/ban/{token}', body:['actorType','actorId','internalNote'] }, + 'ban:remove': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/ban/{token}/{banId}' }, + // --- Avatar --- + 'avatar:get': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/room/{token}/avatar' }, + 'avatar:emoji': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/room/{token}/avatar/emoji', body:['emoji','color'] }, + 'avatar:delete': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/room/{token}/avatar' }, + // --- Breakout rooms --- + 'breakout:configure': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/breakout-rooms/{token}', body:['mode','amount','attendeeMap'] }, + 'breakout:start': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/breakout-rooms/{token}/rooms' }, + 'breakout:stop': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/breakout-rooms/{token}/rooms' }, + 'breakout:remove': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/breakout-rooms/{token}' }, + 'breakout:broadcast': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/breakout-rooms/{token}/broadcast', body:['message'] }, + 'breakout:list': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/breakout-rooms' }, + // --- Recording --- + 'recording:start': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/recording/{token}', body:['status'] }, + 'recording:stop': { method:'DELETE', path:'/ocs/v2.php/apps/spreed/api/v1/recording/{token}' }, + // --- Signaling --- + 'signaling:settings': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v3/signaling/settings', query:['token'] }, + // --- Files integration --- + 'file:getRoomByFileId': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/file/{fileId}' }, + 'file:getRoomByShare': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/publicshare/{shareToken}' }, + // --- Thread --- + 'thread:recent': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/threads/recent', query:['limit'] }, + 'thread:subscribed': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/chat/subscribed-threads', query:['limit','offset'] }, + 'thread:get': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/threads/{threadId}' }, + 'thread:rename': { method:'PUT', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/threads/{threadId}', body:['threadTitle'] }, + 'thread:notify': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/chat/{token}/threads/{messageId}/notify', body:['level'] }, + // --- User settings --- + 'settings:set': { method:'POST', path:'/ocs/v2.php/apps/spreed/api/v1/settings/user', body:['key','value'] }, + // --- Calendar integration --- + 'calendar:dashboard': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v4/dashboard/events' }, + 'calendar:mutual': { method:'GET', path:'/ocs/v2.php/apps/spreed/api/v4/room/{token}/mutual-events' } + }; + + function TalkNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'room:list'; + this.token = config.token; this.messageId = config.messageId; + this.attendeeId = config.attendeeId; this.pollId = config.pollId; + this.banId = config.banId; this.threadId = config.threadId; this.fileId = config.fileId; + this.shareToken = config.shareToken; + // Body fields + this.bodyMessage = config.bodyMessage; this.bodyRoomName = config.bodyRoomName; + this.bodyRoomType = config.bodyRoomType; this.bodyLevel = config.bodyLevel; + this.bodyReplyTo = config.bodyReplyTo; this.bodySilent = config.bodySilent; + this.bodyDescription = config.bodyDescription; this.bodyQuestion = config.bodyQuestion; + this.bodyPassword = config.bodyPassword; this.bodyEmoji = config.bodyEmoji; + this.bodyTimestamp = config.bodyTimestamp; this.bodyReaction = config.bodyReaction; + this.bodyActorType = config.bodyActorType; this.bodyActorId = config.bodyActorId; + this.bodyFlags = config.bodyFlags; this.bodyState = config.bodyState; + this.bodyKey = config.bodyKey; this.bodyValue = config.bodyValue; + this.bodyCalendarUri = config.bodyCalendarUri; this.bodyStart = config.bodyStart; + + if (!this.configNode) { node.error("No Nextcloud configuration node selected"); return; } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { node.error("Unknown operation: " + (msg.operation || node.operation), msg); return; } + + var baseUrl = (node.configNode.baseUrl || '').replace(/\/+$/, ''); + var username = node.configNode.credentials.username; + var password = node.configNode.credentials.password; + + var path = op.path + .replace(/\{token\}/g, msg.token || node.token || '') + .replace(/\{messageId\}/g, msg.messageId || node.messageId || '') + .replace(/\{attendeeId\}/g, msg.attendeeId || node.attendeeId || '') + .replace(/\{pollId\}/g, msg.pollId || node.pollId || '') + .replace(/\{banId\}/g, msg.banId || node.banId || '') + .replace(/\{threadId\}/g, msg.threadId || node.threadId || '') + .replace(/\{fileId\}/g, msg.fileId || node.fileId || '') + .replace(/\{shareToken\}/g, msg.shareToken || node.shareToken || ''); + + var queryParts = []; + if (op.query) { + op.query.forEach(function(q) { + var v = msg[q] !== undefined ? msg[q] : getField(node, q); + if (v !== undefined && v !== '') queryParts.push(encodeURIComponent(q) + '=' + encodeURIComponent(v)); + }); + } + var fullUrl = baseUrl + path + (queryParts.length ? '?' + queryParts.join('&') : ''); + var parsedUrl = url.parse(fullUrl); + + var headers = { + 'OCS-APIRequest': 'true', 'Accept': 'application/json', + 'Authorization': 'Basic ' + Buffer.from(username + ':' + password).toString('base64') + }; + + var bodyObj = {}; + if (op.body) { + op.body.forEach(function(b) { + var v = msg[b] !== undefined ? msg[b] : getField(node, b); + if (v !== undefined && v !== '') bodyObj[b] = v; + }); + } + // Handle special array fields + if (msg.options && Array.isArray(msg.options)) bodyObj.options = msg.options; + if (msg.optionIds && Array.isArray(msg.optionIds)) bodyObj.optionIds = msg.optionIds; + + var bodyStr = null; + if (Object.keys(bodyObj).length > 0) { + headers['Content-Type'] = 'application/json'; + bodyStr = JSON.stringify(bodyObj); + } + + var opts = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), + path: parsedUrl.path, method: op.method, headers: headers, rejectUnauthorized: false + }; + + node.log(op.method + ' ' + fullUrl); + + var transport = parsedUrl.protocol === 'https:' ? https : http; + var req = transport.request(opts, function(res) { + var body = ''; + res.on('data', function(chunk) { body += chunk; }); + res.on('end', function() { + msg.statusCode = res.statusCode; msg.operation = msg.operation || node.operation; + try { msg.payload = JSON.parse(body); } catch(e) { msg.payload = body; } + node.send(msg); + }); + }); + req.on('error', function(err) { msg.error = err.message; node.error(op.method + ' ' + fullUrl + ' — ' + err.message, msg); }); + if (bodyStr) req.write(bodyStr); + req.end(); + }); + } + + function getField(node, name) { + var m = { 'message': node.bodyMessage, 'roomName': node.bodyRoomName, 'roomType': node.bodyRoomType, + 'level': node.bodyLevel, 'replyTo': node.bodyReplyTo, 'silent': node.bodySilent, + 'description': node.bodyDescription, 'question': node.bodyQuestion, 'password': node.bodyPassword, + 'emoji': node.bodyEmoji, 'timestamp': node.bodyTimestamp, 'reaction': node.bodyReaction, + 'actorType': node.bodyActorType, 'actorId': node.bodyActorId, 'flags': node.bodyFlags, + 'state': node.bodyState, 'key': node.bodyKey, 'value': node.bodyValue, + 'calendarUri': node.bodyCalendarUri, 'start': node.bodyStart, 'threadTitle': node.bodyThreadTitle, + 'sendAt': node.bodySendAt, 'pinUntil': node.bodyPinUntil, 'mode': node.bodyMode, + 'amount': node.bodyAmount, 'status': node.bodyStatus }; + return m[name]; + } + + RED.nodes.registerType("talk", TalkNode); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/userstatus.html b/nodes/nextcloud-ocs/userstatus.html new file mode 100644 index 0000000..fbda176 --- /dev/null +++ b/nodes/nextcloud-ocs/userstatus.html @@ -0,0 +1,20 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/userstatus.js b/nodes/nextcloud-ocs/userstatus.js new file mode 100644 index 0000000..e368616 --- /dev/null +++ b/nodes/nextcloud-ocs/userstatus.js @@ -0,0 +1,73 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + 'status:my': { method:'GET', path:'/ocs/v2.php/apps/user_status/api/v1/user_status' }, + 'status:set': { method:'PUT', path:'/ocs/v2.php/apps/user_status/api/v1/user_status/status', body:['statusType'] }, + 'status:predefined': { method:'GET', path:'/ocs/v2.php/apps/user_status/api/v1/predefined_statuses' }, + 'status:setPredefined':{ method:'PUT', path:'/ocs/v2.php/apps/user_status/api/v1/user_status/message/predefined', body:['messageId','clearAt'] }, + 'status:setCustom': { method:'PUT', path:'/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom', body:['statusIcon','message','clearAt'] }, + 'status:clear': { method:'DELETE', path:'/ocs/v2.php/apps/user_status/api/v1/user_status/message' }, + 'status:revert': { method:'DELETE', path:'/ocs/v2.php/apps/user_status/api/v1/user_status/revert/{messageId}' }, + 'status:heartbeat': { method:'PUT', path:'/ocs/v2.php/apps/user_status/api/v1/heartbeat', body:['status'] }, + 'status:list': { method:'GET', path:'/ocs/v2.php/apps/user_status/api/v1/statuses', query:['limit','offset'] }, + 'status:find': { method:'GET', path:'/ocs/v2.php/apps/user_status/api/v1/statuses/{userId}' } + }; + + function USNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'status:my'; + this.userId = config.userId; this.messageId = config.messageId; + + if (!this.configNode) { node.error("No NC config node"); return; } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { node.error("Unknown: "+(msg.operation||node.operation), msg); return; } + + var baseUrl = (node.configNode.baseUrl||'').replace(/\/+$/,''); + var uname = node.configNode.credentials.username; + var pw = node.configNode.credentials.password; + var path = op.path + .replace(/\{userId\}/g, msg.userId||node.userId||'') + .replace(/\{messageId\}/g, msg.messageId||node.messageId||''); + + var qp = []; + if (op.query) op.query.forEach(function(q) { + var v = msg[q]!==undefined ? msg[q] : ''; + if (v!==undefined&&v!=='') qp.push(encodeURIComponent(q)+'='+encodeURIComponent(v)); + }); + var fu = baseUrl+path+(qp.length?'?'+qp.join('&'):''); + var pu = url.parse(fu); + + var h = {'OCS-APIRequest':'true','Accept':'application/json', + 'Authorization':'Basic '+Buffer.from(uname+':'+pw).toString('base64')}; + + var bo={}; + if (op.body) op.body.forEach(function(b){ + var v=msg[b]!==undefined?msg[b]:''; + if(v!==undefined&&v!=='') bo[b]=v; + }); + var bs=null; + if (Object.keys(bo).length>0) {h['Content-Type']='application/json'; bs=JSON.stringify(bo);} + + var o = {hostname:pu.hostname,port:pu.port||(pu.protocol==='https:'?443:80), + path:pu.path,method:op.method,headers:h,rejectUnauthorized:false}; + node.log(op.method+' '+fu); + var t = pu.protocol==='https:'?https:http; + var r = t.request(o,function(res){ + var b='';res.on('data',function(c){b+=c;}); + res.on('end',function(){msg.statusCode=res.statusCode;msg.operation=msg.operation||node.operation; + try{msg.payload=JSON.parse(b);}catch(e){msg.payload=b;}node.send(msg);}); + }); + r.on('error',function(e){msg.error=e.message;node.error(op.method+' '+fu+' - '+e.message,msg);}); + if(bs) r.write(bs); + r.end(); + }); + } + RED.nodes.registerType("userstatus", USNode); +}; \ No newline at end of file diff --git a/nodes/nextcloud-ocs/webhooks.html b/nodes/nextcloud-ocs/webhooks.html new file mode 100644 index 0000000..4881e92 --- /dev/null +++ b/nodes/nextcloud-ocs/webhooks.html @@ -0,0 +1,34 @@ + + \ No newline at end of file diff --git a/nodes/nextcloud-ocs/webhooks.js b/nodes/nextcloud-ocs/webhooks.js new file mode 100644 index 0000000..d6f2194 --- /dev/null +++ b/nodes/nextcloud-ocs/webhooks.js @@ -0,0 +1,91 @@ +module.exports = function(RED) { + var https = require('https'); + var http = require('http'); + var url = require('url'); + + var OPERATIONS = { + 'webhook:list': { method:'GET', path:'/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', query:['uri'] }, + 'webhook:get': { method:'GET', path:'/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{id}' }, + 'webhook:create': { method:'POST', path:'/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', + body:['httpMethod','uri','event','eventFilter','userIdFilter','headers','authMethod','authData','tokenNeeded'] }, + 'webhook:update': { method:'POST', path:'/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{id}', + body:['httpMethod','uri','event','eventFilter','userIdFilter','headers','authMethod','authData','tokenNeeded'] }, + 'webhook:delete': { method:'DELETE', path:'/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{id}' }, + 'webhook:deleteByApp':{ method:'DELETE', path:'/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/byappid/{appid}' } + }; + + function WebhooksNode(config) { + RED.nodes.createNode(this, config); + var node = this; + this.configNode = RED.nodes.getNode(config.nextcloud); + this.operation = config.operation || 'webhook:list'; + this.webhookId = config.webhookId; this.appId = config.appId; + this.bodyHttpMethod = config.bodyHttpMethod; this.bodyUri = config.bodyUri; + this.bodyEvent = config.bodyEvent; this.bodyEventFilter = config.bodyEventFilter; + this.bodyUserIdFilter = config.bodyUserIdFilter; this.bodyHeaders = config.bodyHeaders; + this.bodyAuthMethod = config.bodyAuthMethod; this.bodyAuthData = config.bodyAuthData; + this.bodyTokenNeeded = config.bodyTokenNeeded; + + if (!this.configNode) { node.error("No Nextcloud configuration node selected"); return; } + + node.on('input', function(msg) { + var op = OPERATIONS[msg.operation || node.operation]; + if (!op) { node.error("Unknown operation: " + (msg.operation || node.operation), msg); return; } + + var baseUrl = (node.configNode.baseUrl || '').replace(/\/+$/, ''); + var username = node.configNode.credentials.username; + var password = node.configNode.credentials.password; + + var path = op.path + .replace('{id}', msg.webhookId || node.webhookId || '') + .replace('{appid}', msg.appId || node.appId || ''); + + var queryParts = []; + if (op.query) { + op.query.forEach(function(q) { + var v = msg[q] !== undefined ? msg[q] : getField(node, q); + if (v !== undefined && v !== '') queryParts.push(encodeURIComponent(q) + '=' + encodeURIComponent(v)); + }); + } + var fullUrl = baseUrl + path + (queryParts.length ? '?' + queryParts.join('&') : ''); + var parsedUrl = url.parse(fullUrl); + + var headers = { 'OCS-APIRequest':'true','Accept':'application/json', + 'Authorization':'Basic '+Buffer.from(username+':'+password).toString('base64') }; + + var bodyObj = {}; + if (op.body) { + op.body.forEach(function(b) { + var v = msg[b] !== undefined ? msg[b] : getField(node, b); + if (v !== undefined && v !== '') bodyObj[b] = v; + }); + } + if (msg.eventFilter) bodyObj.eventFilter = msg.eventFilter; + if (msg.headers && typeof msg.headers === 'object') bodyObj.headers = msg.headers; + if (msg.authData) bodyObj.authData = msg.authData; + if (msg.tokenNeeded) bodyObj.tokenNeeded = msg.tokenNeeded; + + var bodyStr = null; + if (Object.keys(bodyObj).length > 0) { headers['Content-Type']='application/json'; bodyStr=JSON.stringify(bodyObj); } + + var opts = { hostname:parsedUrl.hostname, port:parsedUrl.port||(parsedUrl.protocol==='https:'?443:80), + path:parsedUrl.path, method:op.method, headers:headers, rejectUnauthorized:false }; + + node.log(op.method+' '+fullUrl); + var transport = parsedUrl.protocol==='https:'?https:http; + var req = transport.request(opts, function(res) { + var body=''; res.on('data',function(c){body+=c;}); + res.on('end',function(){ msg.statusCode=res.statusCode; msg.operation=msg.operation||node.operation; + try{msg.payload=JSON.parse(body);}catch(e){msg.payload=body;} node.send(msg); }); + }); + req.on('error',function(err){msg.error=err.message;node.error(op.method+' '+fullUrl+' — '+err.message,msg);}); + if(bodyStr) req.write(bodyStr); + req.end(); + }); + } + function getField(node,name) { + var m={ 'httpMethod':node.bodyHttpMethod,'uri':node.bodyUri,'event':node.bodyEvent,'uri':node.bodyUri }; + return m[name]; + } + RED.nodes.registerType("webhooks", WebhooksNode); +}; \ No newline at end of file