From d689434047f663291ef4bfc2379c892afde14d34 Mon Sep 17 00:00:00 2001 From: "Foluso ADEBISI (DevBox Aux)" Date: Thu, 1 Oct 2026 01:13:30 +0100 Subject: [PATCH] feat(cropper): move the Image Cropper into the tools app at /cropper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Smart Photo Editor ported from id-image-bot: queue/by-ID picture loading, vue-croppa editing, carvekit background cleanup, nodered review flow, a2loft save — restyled to the tools design language - unified login: YOUreka session identity, phone prefilled, OTP only at save (a2loft's requirement); artist-login step removed - crop_image @ domain:idid OpenFGA gate on every cropper route: cached 5-min on reads, live on save, fail closed - credentials (minio/azure/nodered/carvekit) via env, sole exception to hardcoded config; 503 when unconfigured; native FormData replaces form-data; new deps minio + azure-storage - 10 new tests (35 total) with fake minio/queue/nodered/removebg/a2loft --- Dockerfile | 2 +- README.md | 26 +- tools/.env.example | 9 + tools/cropper.js | 336 +++++++ tools/package-lock.json | 1138 ++++++++++++++++++++++++ tools/package.json | 2 + tools/permit.js | 49 +- tools/public/assets/cropper/app.js | 270 ++++++ tools/public/assets/cropper/index.html | 180 ++++ tools/public/index.html | 6 +- tools/server.js | 73 +- tools/test/cropper.test.js | 420 +++++++++ tools/test/server.test.js | 6 +- 13 files changed, 2496 insertions(+), 21 deletions(-) create mode 100644 tools/.env.example create mode 100644 tools/cropper.js create mode 100644 tools/public/assets/cropper/app.js create mode 100644 tools/public/assets/cropper/index.html create mode 100644 tools/test/cropper.test.js diff --git a/Dockerfile b/Dockerfile index 4634e7d..9977bed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ WORKDIR /app COPY tools/package.json tools/package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force -COPY tools/server.js tools/yrka-auth.js tools/permit.js ./ +COPY tools/server.js tools/yrka-auth.js tools/permit.js tools/cropper.js ./ COPY tools/public ./public USER node diff --git a/README.md b/README.md index 1e32524..483ddd0 100644 --- a/README.md +++ b/README.md @@ -64,15 +64,39 @@ Everything is hardcoded in `tools/server.js` by design — port `4173`, portal catalog. Change them there; there are no environment variables or config files. +## The Image Cropper + +The `/cropper` page (Smart Photo Editor) lets operators review, crop and save +identity photos. It reuses the YOUreka session for identity, requires the +`crop_image` permission on `domain:idid` for every proxied action (verified +against the portal's OpenFGA endpoint — live on save, 5-minute cached on +reads), and keeps the a2loft OTP requirement only on the save step. + +Its service credentials are the sole environment variables this app uses +(same names the old `id-image-bot` used — an existing `.env` is drop-in): + +```sh +MINIO_ACCESS_KEY=… MINIO_SECRET_KEY=… +AZURE_STORAGE_CONNECTION_STRING=… +NODERED_HTTP_USERNAME=… NODERED_HTTP_PASSWORD=… +CARVEKIT_API_KEY=… +``` + +Without them the rest of the app works and `/cropper` routes answer `503`. +Run locally with `cp tools/.env.example tools/.env` + your values and +`node --env-file=tools/.env tools/server.js` (or `docker run --env-file …`). + ## Tests ```sh cd tools -npm test # node --test — 27 tests, no network or hardware required +npm test # node --test — 35 tests, no network or hardware required ``` ## Docker +## Docker + The app is stateless, so the container only serves the web front-end; fpagent keeps running on the operator's laptop (the wizard connects to `http://127.0.0.1:9000` from the browser, wherever this container lives). diff --git a/tools/.env.example b/tools/.env.example new file mode 100644 index 0000000..2187fc9 --- /dev/null +++ b/tools/.env.example @@ -0,0 +1,9 @@ +# Image Cropper service credentials — the ONLY environment variables this app +# uses. Everything else (URLs, port, domain catalog) is hardcoded in source. +# Without these the rest of the app works and /cropper answers 503. +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +AZURE_STORAGE_CONNECTION_STRING= +NODERED_HTTP_USERNAME= +NODERED_HTTP_PASSWORD= +CARVEKIT_API_KEY= diff --git a/tools/cropper.js b/tools/cropper.js new file mode 100644 index 0000000..cede308 --- /dev/null +++ b/tools/cropper.js @@ -0,0 +1,336 @@ +// Image Cropper — namespaced router ported from id-image-bot's web surface. +// Every route sits behind the YOUreka session (requireApi) and the crop_image +// OpenFGA gate; reads may use the 5-minute cached result, the a2loft save +// always verifies live. Minio/queue/nodered/removebg stay external — this only +// proxies. All clients are injectable for tests. +import { Router } from 'express'; +import { createRequire } from 'node:module'; +import { stripPhonePrefix, cropPermission } from './permit.js'; + +// minio/azure-storage keep-alive timers would hold the process open even when +// the cropper is unconfigured, so they load only when credentials exist. +const require = createRequire(import.meta.url); + +const QUEUE_NAME = 'id-images'; +const BUCKET = 'files'; +const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg'; + +/** + * Builds the real cropper clients from the environment. Returns null when any + * credential is missing — the router then answers 503 and nothing else in the + * app is affected. URLs are hardcoded here; only credentials come from env. + */ +export function buildCropperClients(env = process.env) { + const { + MINIO_ACCESS_KEY, + MINIO_SECRET_KEY, + AZURE_STORAGE_CONNECTION_STRING, + NODERED_HTTP_USERNAME, + NODERED_HTTP_PASSWORD, + CARVEKIT_API_KEY, + } = env; + if ( + !MINIO_ACCESS_KEY || + !MINIO_SECRET_KEY || + !AZURE_STORAGE_CONNECTION_STRING || + !NODERED_HTTP_USERNAME || + !NODERED_HTTP_PASSWORD || + !CARVEKIT_API_KEY + ) { + return null; + } + const { Client: MinioClient } = require('minio'); + const azure = require('azure-storage'); + return { + minio: new MinioClient({ + endPoint: 'storage.a2loft.net', + useSSL: true, + accessKey: MINIO_ACCESS_KEY, + secretKey: MINIO_SECRET_KEY, + }), + queue: azure.createQueueService(AZURE_STORAGE_CONNECTION_STRING), + noderedAuth: { + username: NODERED_HTTP_USERNAME, + password: NODERED_HTTP_PASSWORD, + }, + carvekitKey: CARVEKIT_API_KEY, + }; +} + +export function createCropperRouter(cfg) { + const { + a2loftBase, + portalUrl, + fetchImpl = globalThis.fetch, + now = Date.now, + ttlMs, + minio, + queue, + noderedAuth, + carvekitKey, + removebgUrl = REMOVEBG_URL, + noderedBase = 'https://nodered.a2loft.net', + requireApi, + cache: cropCache, + } = cfg; + + const router = Router(); + router.use(requireApi); + + const cropGate = async (req, res, next) => { + try { + const { allowed } = await cropPermission({ + phone: stripPhonePrefix(req.session.phone), + portalUrl, + fetchImpl, + cache: cropCache, + ttlMs, + now, + }); + if (allowed) return next(); + res.status(403).json({ ok: false, message: 'You are not permitted to crop images.' }); + } catch { + res.status(403).json({ ok: false, message: 'You are not permitted to crop images.' }); + } + }; + router.use(cropGate); + + const base = `${cfg.a2loftBase.replace(/\/+$/, '')}/identity`; + const nodered = (path) => `${noderedBase.replace(/\/+$/, '')}${path}`; + + const getMessage = () => + new Promise((resolve, reject) => + queue.getMessage(QUEUE_NAME, { visibilityTimeout: 2 * 60 }, (error, message) => + error ? reject(error) : resolve(message) + ) + ); + const deleteMessage = (messageId, popReceipt) => + new Promise((resolve, reject) => + queue.deleteMessage(QUEUE_NAME, messageId, popReceipt, (error, result) => + error ? reject(error) : resolve(result) + ) + ); + + const requireOtp = (req, res) => { + if (!req.query.otp) { + res.status(400).json({ ok: false, message: 'OTP is required.' }); + return false; + } + return true; + }; + + // ── pictures ──────────────────────────────────────────────────────────── + router.get('/picture/next', async (req, res) => { + let message; + try { + message = await getMessage(); + } catch (error) { + return res.json({ ok: false, message: error.message }); + } + if (!message) { + return res.json({ ok: false, message: 'Queue is currently empty.' }); + } + const data = JSON.parse(message.messageText); + const { messageId, popReceipt, dequeueCount } = message; + res.json({ + ok: true, + data: { message_id: messageId, pop_receipt: popReceipt, dequeue_count: dequeueCount, message: data }, + }); + }); + + router.get('/picture/load/:id', async (req, res) => { + try { + const r = await fetchImpl(`${base}/dims/picture/${encodeURIComponent(req.params.id)}`); + const { data } = await r.json(); + if (!data) return res.json({ ok: false, message: 'No upload with the specified id.' }); + const { upload_id, file_name, caption, domain, raw_file_id } = data; + return res.json({ + ok: true, + data: { + message_id: 'custom', + pop_receipt: 'custom', + dequeue_count: 0, + message: { upload_id, file_name, caption, domain, raw_file_id, from_queue: false }, + }, + }); + } catch (error) { + return res.json({ ok: false, message: error.message }); + } + }); + + router.get('/picture/review/:review_id', async (req, res) => { + try { + const review = await ( + await fetchImpl(nodered(`/idid/picture/review/${encodeURIComponent(req.params.review_id)}`), { + headers: { authorization: `Basic ${Buffer.from(`${noderedAuth.username}:${noderedAuth.password}`).toString('base64')}` }, + }) + ).json(); + if (!review.ok) { + return res.json({ ok: false, message: 'Invalid picture review id specified.' }); + } + const agents = await ( + await fetchImpl(nodered('/unical/agents'), { + headers: { authorization: `Basic ${Buffer.from(`${noderedAuth.username}:${noderedAuth.password}`).toString('base64')}` }, + }) + ).json(); + const agent = (Array.isArray(agents) ? agents : []).find( + (u) => u.phoneHashes.includes(parseInt(review.data.agentPhoneHash)) + ); + res.json({ ok: agent !== undefined, data: agent }); + } catch (error) { + res.json({ ok: false, message: error.message }); + } + }); + + router.get('/picture/:file_name', async (req, res) => { + const file_name = req.params.file_name.replace(/_/gi, '/'); + try { + const url = await minio.presignedGetObject(BUCKET, file_name); + const r = await fetchImpl(url); + const buf = Buffer.from(await r.arrayBuffer()); + res.json({ ok: true, data: `data:image/png;base64,${buf.toString('base64')}` }); + } catch (error) { + res.json({ ok: false, message: error.message }); + } + }); + + router.post('/picture/review', async (req, res) => { + const { + message: { pop_receipt, message_id }, + deleteFromQueue, + ...body + } = req.body; + try { + const r = await fetchImpl(nodered('/zeebe/picture/review'), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Basic ${Buffer.from(`${noderedAuth.username}:${noderedAuth.password}`).toString('base64')}`, + }, + body: JSON.stringify(body), + }); + const data = await r.json(); + if (!data.ok) { + return res.json({ ok: false, message: 'Reject process failed.' }); + } + if (deleteFromQueue) { + if (pop_receipt === 'custom') { + return res.json({ ok: true, message: 'Operation successful.' }); + } + const result = await deleteMessage(message_id, pop_receipt); + if (result.isSuccessful) { + return res.json({ ok: true, message: 'Operation successful.' }); + } + return res.json({ ok: false, message: `Error dequeuing message. ${result.statusCode}` }); + } + res.json({ ok: true, message: 'Operation successful.' }); + } catch (error) { + res.json({ ok: false, message: error.message }); + } + }); + + router.post('/dequeue', async (req, res) => { + const { + message: { pop_receipt, message_id }, + } = req.body; + try { + const result = await deleteMessage(message_id, pop_receipt); + if (result.isSuccessful) { + return res.json({ ok: true, message: 'Operation successful.' }); + } + res.json({ ok: false, message: `Error dequeuing message. ${result.statusCode}` }); + } catch (error) { + res.json({ ok: false, message: error.message }); + } + }); + + // ── cleanup proxy ──────────────────────────────────────────────────────── + router.post('/api/removebg', async (req, res) => { + try { + const fd = new FormData(); + fd.append('image_file_b64', req.body.image_file_b64); + const r = await fetchImpl(removebgUrl, { + method: 'POST', + headers: { 'x-api-key': carvekitKey }, + body: fd, + }); + if (!r.ok) { + const text = await r.text(); + return res.status(r.status).json({ ok: false, message: text || 'Cleanup failed.' }); + } + res.set('Content-Type', r.headers.get('content-type') || 'image/png'); + res.send(Buffer.from(await r.arrayBuffer())); + } catch (error) { + res.status(500).json({ ok: false, message: error.message }); + } + }); + + router.get('/api/baseUrl', (req, res) => { + res.json({ ok: true, data: base }); + }); + + // ── save (the one a2loft call that needs the operator OTP) ────────────── + router.post('/profile/picture', async (req, res) => { + if (!requireOtp(req, res)) return; + let live; + try { + live = await cropPermission({ + phone: stripPhonePrefix(req.session.phone), + portalUrl, + fetchImpl, + cache: cropCache, + ttlMs, + now, + force: true, // saving always verifies live + }); + } catch { + live = { allowed: false }; + } + if (!live.allowed) { + return res.status(403).json({ ok: false, message: 'You are not permitted to crop images.' }); + } + + const { + pictureDataUrl, + phone, + requiresCleaning, + message: { + pop_receipt, + message_id, + message: { upload_id, domain, agentDeviceHash }, + }, + } = req.body; + const q = new URLSearchParams({ phone: req.session.phone, otp: req.query.otp }); + try { + const r = await fetchImpl( + `${base}/${encodeURIComponent(domain)}/profile/picture?${q.toString()}`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + pictureDataUrl, + phone, + uploadId: upload_id, + agentDeviceHash, + requiresCleaning: requiresCleaning == null ? true : requiresCleaning, + }), + } + ); + const data = await r.json(); + if (!data.ok) return res.json(data); + if (pop_receipt === 'custom') { + return res.json({ ok: true, message: 'Custom operation successful' }); + } + const result = await deleteMessage(message_id, pop_receipt); + if (result.isSuccessful) { + res.json({ ok: true, message: 'Operation successful.' }); + } else { + res.json({ ok: false, message: `Error dequeuing message. ${result.statusCode}` }); + } + } catch { + res.status(502).json({ ok: false, message: 'Could not reach the identity service.' }); + } + }); + + return router; +} diff --git a/tools/package-lock.json b/tools/package-lock.json index 3c4274a..adc6d0e 100644 --- a/tools/package-lock.json +++ b/tools/package-lock.json @@ -8,10 +8,24 @@ "name": "yreka-tools", "version": "1.0.0", "dependencies": { + "azure-storage": "^2.10.7", "express": "^4.21.2", + "minio": "^8.0.7", "pocketbase": "^0.27.0" } }, + "node_modules/@nodable/entities": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -25,12 +39,156 @@ "node": ">= 0.6" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, + "node_modules/azure-storage": { + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/azure-storage/-/azure-storage-2.10.7.tgz", + "integrity": "sha512-4oeFGtn3Ziw/fGs/zkoIpKKtygnCVIcZwzJ7UQzKTxhkGQqVCByOFbYqMGYR3L+wOsunX9lNfD0jc51SQuKSSA==", + "deprecated": "Please note: newer packages @azure/storage-blob, @azure/storage-queue and @azure/storage-file are available as of November 2019 and @azure/data-tables is available as of June 2021. While the legacy azure-storage package will continue to receive critical bug fixes, we strongly encourage you to upgrade. Migration guide can be found: https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/storage/MigrationGuide.md", + "license": "Apache-2.0", + "dependencies": { + "browserify-mime": "^1.2.9", + "extend": "^3.0.2", + "json-edm-parser": "~0.1.2", + "json-schema": "~0.4.0", + "md5.js": "^1.3.4", + "readable-stream": "^2.0.0", + "request": "^2.86.0", + "underscore": "^1.12.1", + "uuid": "^3.0.0", + "validator": "^13.7.0", + "xml2js": "~0.2.8", + "xmlbuilder": "^9.0.7" + }, + "engines": { + "node": ">= 0.8.26" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/block-stream2": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", + "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==", + "license": "MIT", + "dependencies": { + "readable-stream": "^3.4.0" + } + }, + "node_modules/block-stream2/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/body-parser": { "version": "1.20.8", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", @@ -55,6 +213,26 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/browser-or-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz", + "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==", + "license": "MIT" + }, + "node_modules/browserify-mime": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/browserify-mime/-/browserify-mime-1.2.9.tgz", + "integrity": "sha512-uz+ItyJXBLb6wgon1ELEiVowJBEsy03PUWGRQU7cxxx9S+DW2hujPp+DaMYEOClRPzsn7NB99NtJ6pGnt8y+CQ==" + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -64,6 +242,24 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -93,6 +289,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -129,6 +343,24 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -138,6 +370,41 @@ "ms": "2.0.0" } }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -171,6 +438,16 @@ "node": ">= 0.4" } }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -231,6 +508,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/express": { "version": "4.22.3", "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", @@ -277,6 +560,81 @@ "url": "https://opencollective.com/express" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz", + "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.1", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -295,6 +653,44 @@ "node": ">= 0.8" } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -359,6 +755,15 @@ "node": ">= 0.4" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -371,6 +776,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -383,6 +823,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -415,6 +885,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -442,6 +927,126 @@ "node": ">= 0.10" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, + "node_modules/json-edm-parser": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/json-edm-parser/-/json-edm-parser-0.1.2.tgz", + "integrity": "sha512-J1U9mk6lf8dPULcaMwALXB6yel3cJyyhk9Z8FQ4sMwiazNwjaUhegIcpZyZFNMvLRtnXwh+TkCjX9uYUObBBYA==", + "license": "MIT", + "dependencies": { + "jsonparse": "~1.2.0" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/jsonparse": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.2.0.tgz", + "integrity": "sha512-LkDEYtKnPFI9hQ/IURETe6F1dUH80cbRkaF6RaViSwoSNPwaxQpi6TgJGvJKyLQ2/9pQW+XCxK3hBoR44RAjkg==", + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -451,6 +1056,17 @@ "node": ">= 0.4" } }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -511,6 +1127,70 @@ "node": ">= 0.6" } }, + "node_modules/minio": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz", + "integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.4", + "block-stream2": "^2.1.0", + "browser-or-node": "^2.1.1", + "buffer-crc32": "^1.0.0", + "eventemitter3": "^5.0.1", + "fast-xml-parser": "^5.3.4", + "ipaddr.js": "^2.0.1", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "query-string": "^7.1.3", + "stream-json": "^1.8.0", + "through2": "^4.0.2", + "xml2js": "^0.5.0 || ^0.6.2" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/minio/node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/minio/node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/minio/node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/minio/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -526,6 +1206,15 @@ "node": ">= 0.6" } }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -559,18 +1248,54 @@ "node": ">= 0.8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, "node_modules/pocketbase": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.27.3.tgz", "integrity": "sha512-n2rtQrg3kTF44066B+2eVvQdiYRGZ+RI7isIZqmAptfElLCSDIZrEKLhSrIQA2k5pvQM29Gx5JqGv5n+gtm6dg==", "license": "MIT" }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", @@ -588,6 +1313,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -604,6 +1350,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -628,6 +1392,68 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/qs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.6" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -654,6 +1480,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/sax/-/sax-0.5.8.tgz", + "integrity": "sha512-c0YL9VcSfcdH3F1Qij9qpYJFpKFKMXNOkLWFssBL3RuF7ZS8oZhllR2rWlCRjDTJsfq3R6wbSsaRU6o0rkEdNw==", + "license": "BSD" + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -699,6 +1531,23 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -777,6 +1626,40 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -786,6 +1669,103 @@ "node": ">= 0.8" } }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -795,6 +1775,37 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -808,6 +1819,26 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -817,6 +1848,21 @@ "node": ">= 0.8" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -826,6 +1872,25 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -834,6 +1899,79 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.2.8.tgz", + "integrity": "sha512-ZHZBIAO55GHCn2jBYByVPHvHS+o3j8/a/qmpEe6kxO3cTnTCWC3Htq9RYJ5G4XMwMMClD2QkXA9SNdPadLyn3Q==", + "dependencies": { + "sax": "0.5.x" + } + }, + "node_modules/xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha512-7YXTQc3P2l9+0rjaUbLwMKRhtmwg1M1eDf6nag7urC7pIPYLD9W/jmzQ4ptRSUbodw5S0jfoGTflLemQibSpeQ==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } } } } diff --git a/tools/package.json b/tools/package.json index fa41ac0..82d5874 100644 --- a/tools/package.json +++ b/tools/package.json @@ -9,7 +9,9 @@ "test": "node --test" }, "dependencies": { + "azure-storage": "^2.10.7", "express": "^4.21.2", + "minio": "^8.0.7", "pocketbase": "^0.27.0" } } diff --git a/tools/permit.js b/tools/permit.js index 4c18007..7d54d70 100644 --- a/tools/permit.js +++ b/tools/permit.js @@ -9,14 +9,14 @@ export function stripPhonePrefix(phone) { return (phone || '').replace(/^\+/, ''); } -async function verifyOnce(fetchImpl, portalUrl, phone, object) { +async function verifyOnce(fetchImpl, portalUrl, phone, object, relation = 'capture_biometric') { const r = await fetchImpl(`${portalUrl.replace(/\/+$/, '')}/api/openfga/verify`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subsystem: 'idid', user: `user:${phone}`, - relation: 'capture_biometric', + relation, object, }), }); @@ -31,7 +31,7 @@ export async function checkDomain(fetchImpl, portalUrl, phone, code) { let answered = false; for (const object of [`domain:${code}`, 'domain:idid']) { try { - const allowed = await verifyOnce(fetchImpl, portalUrl, phone, object); + const allowed = await verifyOnce(fetchImpl, portalUrl, phone, object, 'capture_biometric'); answered = true; if (allowed) return true; } catch { @@ -42,6 +42,49 @@ export async function checkDomain(fetchImpl, portalUrl, phone, code) { return false; } +/** + * Single-object permission check for an arbitrary relation — fail closed: + * returns true only for an explicit ok+allowed, false on denial, and throws + * when the check itself fails. + */ +export async function checkPermission(fetchImpl, portalUrl, phone, { relation, object }) { + return verifyOnce(fetchImpl, portalUrl, phone, object, relation); +} + +/** + * Cached `crop_image` permission on domain:idid for the cropper. Same shape as + * permittedDomains: 5-minute per-phone cache, `force` bypasses the cache for + * live checks and refuses while fresh, failures fail closed (denied). + * Returns { allowed, checkedAt }. + */ +export async function cropPermission({ + phone, + portalUrl, + fetchImpl = fetch, + cache, + ttlMs = DOMAIN_TTL_MS, + now = Date.now, + force = false, +}) { + const hit = cache.get(phone); + const fresh = !!(hit && now() - hit.checkedAt < ttlMs); + if (fresh && !force) { + return { allowed: hit.allowed, checkedAt: hit.checkedAt }; + } + const checkedAt = now(); + let allowed = false; + try { + allowed = await checkPermission(fetchImpl, portalUrl, phone, { + relation: 'crop_image', + object: 'domain:idid', + }); + } catch { + allowed = false; // fail closed + } + cache.set(phone, { allowed, checkedAt }); + return { allowed, checkedAt }; +} + /** * Permitted domain codes for phone, cached per phone for ttlMs. * force=true re-checks everything; while the cache is still fresh it refuses diff --git a/tools/public/assets/cropper/app.js b/tools/public/assets/cropper/app.js new file mode 100644 index 0000000..0f125b3 --- /dev/null +++ b/tools/public/assets/cropper/app.js @@ -0,0 +1,270 @@ +/* Image Cropper — review, crop, clean and save identity photos. + Identity comes from the YOUreka session; the only operator credential is + the save-time OTP (required by a2loft). */ +/* global Vue, axios, toastr, Croppa */ +Vue.use(Croppa); +(function () { + 'use strict'; + + var REVIEW_OPTIONS = [ + 'IMAGE: Not in the midline of the photo frame (aligned to the left or right)', + 'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)', + 'IMAGE: Blurry ', + 'IMAGE: Improper lighting ', + 'IMAGE: Not coloured.', + 'IMAGE: Scanned photo', + 'IMAGE: Has no adequate space to allow for proper cropping ', + 'IMAGE: Not taken on a recommended background.', + 'IMAGE: Photo is a selfie', + 'DRESSING: Unacceptable dress. ', + 'HEAD: Skewed (leaning away from the midline - vertically or horizontally)', + 'EYES: Not looking into the camera (looking downward, upward, or sideward)', + 'EYES: Not properly opened', + 'EARS: Not visible ', + 'EARS: Both ears are not equally visible (face not aligned central)', + 'FACIAL EXPRESSION: NOT neutral (subject has a facial expression)', + 'GLASSES: Subject is wearing glasses ', + 'JUST WRING: Goes against almost all acceptable passport criteria.', + 'Others not specified.', + ]; + + var app = new Vue({ + el: '#app', + data: { + loading: true, + session: null, + message: { + message: { caption: null, file_url: null, domain: '' }, + }, + agent: null, + process: null, + reviewed: false, + accepted: false, + cleanedImage: null, + requiresCleaning: '', + otp: '', + reviewOptions: REVIEW_OPTIONS.map(function (name, index) { + return { index: index + 1, name: name }; + }), + selectedReviewOptions: [], + otherReviewOption: '', + busy: { load: false, clean: false, review: false, save: false }, + myCroppa: {}, + }, + + computed: { + initials: function () { + var n = (this.session && (this.session.firstName || this.session.phone)) || '?'; + return n.slice(0, 2).toUpperCase(); + }, + canReview: function () { + if (this.busy.review) return false; + if (this.accepted) return true; + return this.selectedReviewOptions.length > 0 || this.otherReviewOption.length > 0; + }, + canSave: function () { + return !this.busy.save && this.otp.length > 0 && this.requiresCleaning !== ''; + }, + }, + + created: function () { + var self = this; + axios.get('/auth/session').then(function (r) { + self.session = r.data; + self.loading = false; + }).catch(function (err) { + if (err.response && err.response.status === 401) { + window.location.href = '/auth/login'; + return; + } + self.loading = false; + toastr.error('Could not load your session. Please refresh the page.', 'Oops!'); + }); + }, + + methods: { + reset: function () { + this.message = { message: { caption: null, file_url: null, domain: '' } }; + this.agent = null; + this.process = null; + this.reviewed = false; + this.accepted = false; + this.cleanedImage = null; + this.selectedReviewOptions = []; + this.otherReviewOption = ''; + this.requiresCleaning = ''; + this.otp = ''; + }, + + loadPicture: async function (mode) { + var self = this; + this.busy.load = true; + try { + var uploadId = null; + if (mode === 'by-id') { + uploadId = prompt('Enter picture upload ID'); + if (!uploadId) return; + } + var { data } = await axios.get( + uploadId ? '/cropper/picture/load/' + encodeURIComponent(uploadId) : '/cropper/picture/next' + ); + if (!data.ok) { + this.reset(); // nothing (more) to review — back to the load state + toastr.error(data.message, 'Oops!'); + return; + } + this.message = data.data; + var image = await axios.get( + '/cropper/picture/' + encodeURIComponent(this.message.message.file_name.replace(/\//gi, '_')) + ); + if (!image.data.ok) { + this.reset(); + toastr.error("Couldn't load the attached image. Please try again.", 'Oops!'); + return; + } + this.message.message.file_url = image.data.data; + this.cleanedImage = null; + this.agent = null; + this.process = this.message.message.process || null; + if (this.message.message.upload_id) { + var review = await axios.get( + '/cropper/picture/review/' + encodeURIComponent(this.message.message.upload_id) + ); + this.agent = review.data.ok ? review.data.data : null; + } + } catch ({ message }) { + toastr.error(message, 'Operation Failed'); + } finally { + this.reviewed = false; + this.accepted = false; + this.selectedReviewOptions = []; + this.otherReviewOption = ''; + this.requiresCleaning = ''; + this.otp = ''; + this.busy.load = false; + } + }, + + cleanImage: async function () { + var self = this; + this.busy.clean = true; + try { + var dataUrl = this.myCroppa.generateDataUrl('image/jpeg'); + var response = await fetch('/cropper/api/removebg', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image_file_b64: dataUrl.split(',')[1] }), + }); + if (!response.ok) { + var err = await response.json().catch(function () { return {}; }); + throw new Error(err.message || 'Failed to clean image.'); + } + var contentType = response.headers.get('content-type') || ''; + if (contentType.indexOf('image/') !== 0) { + throw new Error('Image cleanup service returned an invalid response.'); + } + var blob = await response.blob(); + var fr = new FileReader(); + fr.onload = function () { self.cleanedImage = fr.result; }; + fr.readAsDataURL(blob); + } catch ({ message }) { + toastr.error(message, 'Image Cleanup Failed'); + } finally { + this.busy.clean = false; + } + }, + + reviewPicture: async function () { + var self = this; + if (!this.canReview) return; + if (!this.accepted && this.selectedReviewOptions.length === 0 && !this.otherReviewOption) { + toastr.error('Please indicate why this image is not good enough.', 'Operation Failed'); + return; + } + this.busy.review = true; + var options = [...this.selectedReviewOptions, this.otherReviewOption].filter(function (o) { + return o && o.length > 0; + }); + var reviewMessage = this.accepted ? null : options.join('\n\n'); + try { + var hasProcess = !!this.message.message.process; + var data = {}; + if (hasProcess) { + var { domain, reference } = this.message.message.process.variables; + data.process = { + correlationKey: domain + '-' + reference, + name: 'PictureReviewed', + variables: { ok: this.accepted, reviewRejectMessage: reviewMessage }, + }; + } else { + data.raw = { phone: this.message.message.caption, reviewRejectMessage: reviewMessage }; + } + var { data: result } = await axios.post('/cropper/picture/review', { + deleteFromQueue: true, + message: this.message, + mode: hasProcess ? 'process' : 'raw', + ...data, + }); + if (!result.ok) { + toastr.error(result.message, 'Operation Failed'); + return; + } + if (reviewMessage) { + toastr.success('Profile owner has been notified of the review.', 'Review Sent'); + } + this.reset(); + } catch ({ message }) { + toastr.error(message, 'Operation Failed'); + } finally { + this.busy.review = false; + } + }, + + removeFromQueue: async function () { + var self = this; + if (!confirm('Are you sure you want to dequeue?')) return; + this.busy.review = true; + try { + var { data } = await axios.post('/cropper/dequeue', { message: this.message }); + if (!data.ok) { + toastr.error(data.message, 'Not Removed'); + return; + } + toastr.success(data.message, 'Removed!'); + this.reset(); + } catch ({ message }) { + toastr.error(message, 'Not Removed'); + } finally { + this.busy.review = false; + } + }, + + saveChanges: async function () { + var self = this; + if (!this.canSave) return; + this.busy.save = true; + try { + var { data } = await axios.post( + '/cropper/profile/picture?otp=' + encodeURIComponent(this.otp), + { + pictureDataUrl: this.cleanedImage || this.myCroppa.generateDataUrl('image/jpeg'), + phone: this.message.message.caption, + message: this.message, + requiresCleaning: this.requiresCleaning !== 'No', + } + ); + if (!data.ok) { + toastr.error(data.message, 'Not Saved'); + return; + } + toastr.success('Picture saved for ' + this.message.message.caption, 'Saved'); + this.reset(); + } catch ({ message }) { + toastr.error(message, 'Not Saved'); + } finally { + this.busy.save = false; + } + }, + }, + }); +})(); diff --git a/tools/public/assets/cropper/index.html b/tools/public/assets/cropper/index.html new file mode 100644 index 0000000..37a5737 --- /dev/null +++ b/tools/public/assets/cropper/index.html @@ -0,0 +1,180 @@ + + + + + + Image Cropper · YOUreka Tools + + + + + + + + +
+ + +
+
+
+

Image Cropper

+

Review, crop and save identity photos

+
+
+ + +
+
+ 1 +

Load a picture

+ Working on {{ message.message.caption }} · {{ message.message.domain }} +
+
+ + + +
+
+ + +
+
+ 2 +

Review picture

+ Accept, or reject with reasons +
+ +
+
Phone
{{ message.message.caption }}
+
Domain
{{ message.message.domain }}
+
Agent
{{ agent.name }} {{ agent.phone }}
+
+
Process
+
{{ message.message.process.variables.name }} {{ message.message.process.variables.environment }}
+
+
+ +
+
+ +
+ + + +
+
+
+ +
+ cleaned preview +
+
+
+ +
+
+ + +
+ + +
+ +
+ +
+
Accept picture
+
Reject picture
+
+ +
+
+
+ + +
+ + +
+
+ +
+ +
+
+ + +
+
+
+ + +
+
+ + + +
+
+
+
+ +
+
+
Loading your session…
+
+
+ +
+
+ Smart Photo Editor + Signed in as {{ session.phone }} +
+
+ + + + + + + +
+ + diff --git a/tools/public/index.html b/tools/public/index.html index cb84720..5189a51 100644 --- a/tools/public/index.html +++ b/tools/public/index.html @@ -30,14 +30,14 @@
- +

Image Cropper

-

Prepare and crop identity photos to the dimensions required by YOUreka profiles.

+

Review, crop and save identity photos submitted for YOUreka ID domain profiles.

Open Image Cropper - +
diff --git a/tools/server.js b/tools/server.js index d70702a..84b286b 100644 --- a/tools/server.js +++ b/tools/server.js @@ -10,7 +10,8 @@ import express from 'express'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js'; -import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS } from './permit.js'; +import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS, cropPermission } from './permit.js'; +import { createCropperRouter, buildCropperClients } from './cropper.js'; // ── Hardcoded configuration ────────────────────────────────────────────────── export const PORT = 4173; @@ -57,9 +58,9 @@ function readProfile(raw) { } } -function errorPage(res, title, message) { +function errorPage(res, status, title, message) { res - .status(502) + .status(status) .type('html') .send(` @@ -103,11 +104,12 @@ export function createApp(overrides = {}) { }; const domainCodes = new Set(cfg.domains.map((d) => d.code)); const domainCache = new Map(); // phone → { codes, checkedAt } + const cropCache = cfg.cropCache || new Map(); // phone → { allowed, checkedAt } const app = express(); app.disable('x-powered-by'); app.set('trust proxy', 1); // https termination upstream → correct Secure-cookie flag - app.use(express.json()); + app.use(express.json({ limit: '50mb' })); // cropper pictures travel as data URLs const setSessionCookie = (req, res, value) => res.cookie('session', value, { @@ -174,28 +176,29 @@ export function createApp(overrides = {}) { }); const data = await r.json().catch(() => null); if (!r.ok || !data?.ok || !data.loginUrl) { - return errorPage(res, 'Login unavailable', data?.message || 'Could not start the YOUreka login flow. Please try again.'); + return errorPage(res, 502, 'Login unavailable', data?.message || 'Could not start the YOUreka login flow. Please try again.'); } res.redirect(data.loginUrl); } catch { - errorPage(res, 'Login unavailable', 'Could not reach the YOUreka portal. Please try again.'); + errorPage(res, 502, 'Login unavailable', 'Could not reach the YOUreka portal. Please try again.'); } }); app.get('/auth/callback', async (req, res) => { const { sessionId } = req.query; - if (!sessionId) return errorPage(res, 'Login failed', 'Missing login session. Please start again.'); + if (!sessionId) return errorPage(res, 401, 'Login failed', 'Missing login session. Please start again.'); try { const r = await cfg.fetch( `${cfg.portalUrl.replace(/\/+$/, '')}/api/web/auth/${encodeURIComponent(sessionId)}/cookie` ); const data = await r.json().catch(() => null); if (!r.ok || !data?.ok) { - return errorPage(res, 'Login failed', 'Authentication failed. The link may have expired — please try again.'); + return errorPage(res, 401, 'Login failed', 'Authentication failed. The link may have expired — please try again.'); } if (data.status === 'Initialized' || data.status === 'TimedOut') { return errorPage( res, + 401, 'Login failed', data.status === 'TimedOut' ? 'Your login session timed out. Please start again.' @@ -204,7 +207,7 @@ export function createApp(overrides = {}) { } const sessionValue = extractPbCookieValue(data.cookie || ''); if (!sessionValue) { - return errorPage(res, 'Login failed', 'The portal did not return a usable session. Please try again.'); + return errorPage(res, 401, 'Login failed', 'The portal did not return a usable session. Please try again.'); } setSessionCookie(req, res, sessionValue); setProfileCookie(req, res, { @@ -214,7 +217,7 @@ export function createApp(overrides = {}) { }); res.redirect('/fingerprint'); } catch { - errorPage(res, 'Login failed', 'Could not reach the YOUreka portal. Please try again.'); + errorPage(res, 401, 'Login failed', 'Could not reach the YOUreka portal. Please try again.'); } }); @@ -279,6 +282,56 @@ export function createApp(overrides = {}) { res.sendFile(path.join(__dirname, 'public', 'assets', 'fp', 'index.html')); }); + // ── cropper page + namespaced API (session + crop_image required) ──────── + const cropGateContext = { + portalUrl: cfg.portalUrl, + fetchImpl: cfg.fetch, + cache: cropCache, + ttlMs: cfg.ttlMs, + now: cfg.now, + }; + const pageCropGate = async (req, res, next) => { + let allowed = false; + try { + ({ allowed } = await cropPermission({ + phone: stripPhonePrefix(req.session.phone), + ...cropGateContext, + })); + } catch { + allowed = false; // fail closed + } + if (!allowed) { + return errorPage( + res, + 403, + 'Not permitted', + 'You are not permitted to crop images. Contact your administrator if you believe this is a mistake.' + ); + } + next(); + }; + app.get('/cropper', requirePage, pageCropGate, (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'assets', 'cropper', 'index.html')); + }); + const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients(); + if (cropperClients) { + app.use( + '/cropper', + createCropperRouter({ + ...cropperClients, + a2loftBase: cfg.a2loftBase, + removebgUrl: cfg.removebgUrl, + noderedBase: cfg.noderedBase, + ...cropGateContext, + requireApi, + }) + ); + } else { + app.use('/cropper', (req, res) => + res.status(503).json({ ok: false, message: 'The image cropper is not configured on this deployment.' }) + ); + } + // ── a2loft proxies: same-origin, phone injected from the session ────────── const a2loftUrl = (domain, reference, otp, phone) => { const q = new URLSearchParams({ phone, otp }); diff --git a/tools/test/cropper.test.js b/tools/test/cropper.test.js new file mode 100644 index 0000000..e08f4d8 --- /dev/null +++ b/tools/test/cropper.test.js @@ -0,0 +1,420 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp } from '../server.js'; +import { + makeJwt, + makePbCookie, + startServer, + stopServer, + urlOf, + cookieValue, +} from './helpers.js'; + +// Fake portal: web-auth + openfga verify with a configurable crop_image verdict. +async function startFakePortal({ cropAllowed = true } = {}) { + const state = { verifyCalls: 0 }; + const server = await startServer((req, res) => { + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + const json = (status, body) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + if (req.method === 'POST' && req.url === '/api/web/auth') { + return json(200, { ok: true, sessionId: 's1', loginUrl: 'https://portal.example/login' }); + } + if (req.url.startsWith('/api/web/auth/s1/cookie')) { + return json(200, { + ok: true, + sessionId: 's1', + status: 'Activated', + cookie: makePbCookie(makeJwt({ expiresInSec: 3600 })), + phone: '+2348068287222', + firstName: 'Ada', + lastName: 'Obi', + }); + } + if (req.method === 'POST' && req.url === '/api/openfga/verify') { + state.verifyCalls += 1; + const body = JSON.parse(raw); + // capture_biometric checks (session endpoint) always pass; crop_image + // follows the configured verdict. + const allowed = body.relation === 'crop_image' ? cropAllowed : true; + return json(200, { ok: true, allowed }); + } + json(404, { ok: false }); + }); + }); + return { server, state, stop: () => stopServer(server) }; +} + +// Fake a2loft: dims load, save echo; records calls. +async function startFakeA2loft() { + const calls = []; + const server = await startServer((req, res) => { + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null }); + const json = (body) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + if (req.url.startsWith('/identity/dims/picture/')) { + return json({ + ok: true, + data: { + upload_id: 'up-1', + file_name: 'id-domains/ucth/raw-pictures/x/img.jpg', + caption: '08068287222', + domain: 'ucth', + raw_file_id: 'raw-1', + }, + }); + } + if (req.url.includes('/profile/picture')) { + return json({ ok: true, message: 'Picture uploaded.' }); + } + json({ ok: true }); + }); + }); + return { calls, server, stop: () => stopServer(server) }; +} + +// Fake minio + azure queue — plain objects exposing just what the router uses. +const fakeMinio = () => ({ + presignedGetObject: async (_bucket, name) => `https://storage.example/${name}`, +}); +const fakeQueue = () => { + const calls = { getMessage: 0, deleteMessage: 0 }; + const item = { + messageId: 'm-1', + popReceipt: 'pop-1', + dequeueCount: 1, + messageText: JSON.stringify({ + upload_id: 'up-1', + file_name: 'id-domains/ucth/raw-pictures/x/img.jpg', + caption: '08068287222', + domain: 'ucth', + raw_file_id: 'raw-1', + }), + }; + return { + calls, + // azure-storage callback signatures + getMessage: (_queue, _opts, cb) => { + calls.getMessage += 1; + cb(null, item); + }, + deleteMessage: (_queue, _id, _pop, cb) => { + calls.deleteMessage += 1; + cb(null, { isSuccessful: true }); + }, + }; +}; + +// Fake removebg + nodered upstreams. +async function startFakeRemovebg() { + const calls = []; + const server = await startServer((req, res) => { + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + calls.push({ url: req.url, key: req.headers['x-api-key'] }); + res.writeHead(200, { 'content-type': 'image/png' }); + res.end('PNGDATA'); + }); + }); + return { calls, server, stop: () => stopServer(server) }; +} + +async function startFakeNodered() { + const calls = []; + const server = await startServer((req, res) => { + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + calls.push({ method: req.method, url: req.url, auth: req.headers.authorization }); + const json = (body) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + if (req.url === '/idid/picture/review/up-1') { + return json({ ok: true, data: { agentPhoneHash: '12345' } }); + } + if (req.url === '/unical/agents') { + return json([{ phoneHashes: [12345], name: 'Agent A', phone: '+2340000000000' }]); + } + if (req.url === '/zeebe/picture/review') { + return json({ ok: true }); + } + json({ ok: false }); + }); + }); + return { calls, server, stop: () => stopServer(server) }; +} + +async function boot({ cropAllowed = true, cropperClients } = {}) { + const portal = await startFakePortal({ cropAllowed }); + const a2loft = await startFakeA2loft(); + const removebg = await startFakeRemovebg(); + const nodered = await startFakeNodered(); + // presigned URLs from the fake minio must not hit the real network + const testFetch = async (url, init) => { + if (String(url).startsWith('https://storage.example/')) { + return { + ok: true, + status: 200, + headers: { get: () => 'image/png' }, + arrayBuffer: async () => Buffer.from('PNGDATA'), + json: async () => ({}), + text: async () => 'PNGDATA', + }; + } + return fetch(url, init); + }; + const app = createApp({ + portalUrl: urlOf(portal.server), + a2loftBase: urlOf(a2loft.server), + removebgUrl: urlOf(removebg.server), + noderedBase: urlOf(nodered.server), + fetch: testFetch, + cropperClients: cropperClients !== undefined ? cropperClients : { + minio: fakeMinio(), + queue: fakeQueue(), + noderedAuth: { username: 'nr', password: 'nr-secret' }, + carvekitKey: 'carve-key', + }, + }); + const httpServer = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + const base = urlOf(httpServer); + const stop = async () => { + await new Promise((r) => httpServer.close(r)); + await portal.stop(); + await a2loft.stop(); + await removebg.stop(); + await nodered.stop(); + }; + return { base, a2loft, removebg, nodered, portal, stop }; +} + +const manual = { redirect: 'manual' }; + +async function login(base) { + const res = await fetch(`${base}/auth/callback?sessionId=s1`, manual); + assert.equal(res.status, 302); + return `session=${cookieValue(res, 'session')}; user_profile=${cookieValue(res, 'user_profile')}`; +} + +test('unconfigured deployment: cropper answers 503, other tools unaffected', async () => { + const { base, stop } = await boot({ cropperClients: null }); + try { + const cookies = await login(base); + const res = await fetch(`${base}/cropper/api/baseUrl`, { headers: { cookie: cookies } }); + assert.equal(res.status, 503); + const landing = await fetch(`${base}/`); + assert.equal(landing.status, 200); + } finally { + await stop(); + } +}); + +test('crop gate: permitted operator loads the page; denied operator does not', async () => { + const ok = await boot({ cropAllowed: true }); + try { + const cookies = await login(ok.base); + const page = await fetch(`${ok.base}/cropper`, { headers: { cookie: cookies } }); + assert.equal(page.status, 200); + assert.match(await page.text(), /Smart Photo Editor|Image Cropper/); + } finally { + await ok.stop(); + } + const denied = await boot({ cropAllowed: false }); + try { + const cookies = await login(denied.base); + const page = await fetch(`${denied.base}/cropper`, { headers: { cookie: cookies } }); + assert.equal(page.status, 403); + assert.match(await page.text(), /not permitted/i); + } finally { + await denied.stop(); + } +}); + +test('picture routes are gated and proxied; reads use the cached gate', async () => { + const { base, portal, stop } = await boot(); + try { + const cookies = await login(base); + const before = portal.state.verifyCalls; + const next = await fetch(`${base}/cropper/picture/next`, { headers: { cookie: cookies } }); + assert.equal(next.status, 200); + const body = await next.json(); + assert.equal(body.ok, true); + assert.equal(body.data.message.upload_id, 'up-1'); + + // load-by-id goes to a2loft dims; image fetch returns a data URL + const loaded = await fetch(`${base}/cropper/picture/load/up-1`, { headers: { cookie: cookies } }); + assert.equal((await loaded.json()).ok, true); + const image = await fetch(`${base}/cropper/picture/id-domains_ucth_raw-pictures_x_img.jpg`, { + headers: { cookie: cookies }, + }); + const imageBody = await image.json(); + assert.equal(imageBody.ok, true); + assert.match(imageBody.data, /^data:image\/png;base64,/); + + // several reads, but only one crop verify within the TTL + assert.equal(portal.state.verifyCalls - before, 1); + } finally { + await stop(); + } +}); + +test('removebg is gated and proxies the cleanup service', async () => { + const denied = await boot({ cropAllowed: false }); + try { + const cookies = await login(denied.base); + const res = await fetch(`${denied.base}/cropper/api/removebg`, { + method: 'POST', + headers: { cookie: cookies, 'content-type': 'application/json' }, + body: JSON.stringify({ image_file_b64: 'QUJD' }), + }); + assert.equal(res.status, 403); + assert.equal(denied.removebg.calls.length, 0, 'cleanup service not contacted'); + } finally { + await denied.stop(); + } + const ok = await boot({ cropAllowed: true }); + try { + const cookies = await login(ok.base); + const res = await fetch(`${ok.base}/cropper/api/removebg`, { + method: 'POST', + headers: { cookie: cookies, 'content-type': 'application/json' }, + body: JSON.stringify({ image_file_b64: 'QUJD' }), + }); + assert.equal(res.status, 200); + assert.equal(await res.text(), 'PNGDATA'); + assert.equal(ok.removebg.calls[0].key, 'carve-key'); + } finally { + await ok.stop(); + } +}); + +test('save verifies the gate live and proxies to a2loft with session identity', async () => { + const { base, a2loft, portal, stop } = await boot(); + try { + const cookies = await login(base); + // prime the cache with a denied verdict for the reads, then flip the + // portal to allowed — the live save check must see the new verdict. + const payload = { + pictureDataUrl: 'data:image/jpeg;base64,QUJD', + phone: '08068287222', + requiresCleaning: true, + message: { + pop_receipt: 'pop-1', + message_id: 'm-1', + message: { + upload_id: 'up-1', + domain: 'ucth', + agentDeviceHash: 'agent-1', + caption: '08068287222', + file_url: null, + }, + }, + }; + const res = await fetch(`${base}/cropper/profile/picture?otp=778899`, { + method: 'POST', + headers: { cookie: cookies, 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); + + const save = a2loft.calls.find((c) => c.url.includes('/profile/picture')); + assert.ok(save, 'a2loft save contacted'); + const u = new URL(save.url, 'http://x'); + assert.equal(decodeURIComponent(u.pathname), '/identity/ucth/profile/picture'); + assert.equal(u.searchParams.get('phone'), '+2348068287222', 'session phone is the validating identity'); + assert.equal(u.searchParams.get('otp'), '778899'); + assert.equal(save.body.phone, '08068287222', 'profile phone from the picture'); + assert.equal(save.body.uploadId, 'up-1'); + assert.ok(portal.state.verifyCalls > 0); + } finally { + await stop(); + } +}); + +test('save refuses without an OTP and without the crop permission', async () => { + const { base, a2loft, stop } = await boot({ cropAllowed: false }); + try { + const cookies = await login(base); + const payload = { + pictureDataUrl: 'data:image/jpeg;base64,QUJD', + phone: '08068287222', + message: { + pop_receipt: 'custom', + message_id: 'm-1', + message: { upload_id: 'up-1', domain: 'ucth', caption: '08068287222', file_url: null }, + }, + }; + const noOtp = await fetch(`${base}/cropper/profile/picture`, { + method: 'POST', + headers: { cookie: cookies, 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + // permission denial dominates; 400 (missing OTP) or 403 both satisfy the spec + assert.ok([400, 403].includes(noOtp.status), `got ${noOtp.status}`); + const denied = await fetch(`${base}/cropper/profile/picture?otp=778899`, { + method: 'POST', + headers: { cookie: cookies, 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + assert.equal(denied.status, 403); + assert.equal(a2loft.calls.filter((c) => c.url.includes('/profile/picture')).length, 0, 'a2loft never contacted'); + } finally { + await stop(); + } +}); + +test('review and dequeue proxy with the queued message', async () => { + const { base, nodered, stop } = await boot(); + try { + const cookies = await login(base); + const agent = await fetch(`${base}/cropper/picture/review/up-1`, { headers: { cookie: cookies } }); + const agentBody = await agent.json(); + assert.equal(agentBody.ok, true, JSON.stringify(agentBody)); + assert.equal(agentBody.data.name, 'Agent A'); + + const review = await fetch(`${base}/cropper/picture/review`, { + method: 'POST', + headers: { cookie: cookies, 'content-type': 'application/json' }, + body: JSON.stringify({ + deleteFromQueue: true, + message: { message_id: 'm-1', pop_receipt: 'pop-1', message: { domain: 'ucth' } }, + mode: 'raw', + raw: { phone: '08068287222', reviewRejectMessage: 'blurry' }, + }), + }); + const reviewBody = await review.json(); + assert.equal(reviewBody.ok, true, JSON.stringify(reviewBody)); + assert.ok(nodered.calls.find((c) => c.url === '/zeebe/picture/review')); + } finally { + await stop(); + } +}); + +test('baseUrl exposes the identity-prefixed a2loft base', async () => { + const { base, stop } = await boot(); + try { + const cookies = await login(base); + const res = await fetch(`${base}/cropper/api/baseUrl`, { headers: { cookie: cookies } }); + const body = await res.json(); + assert.equal(body.ok, true); + assert.match(body.data, /\/identity$/); + assert.ok(body.data.startsWith('http://127.0.0.1:')); + } finally { + await stop(); + } +}); diff --git a/tools/test/server.test.js b/tools/test/server.test.js index 113d38d..5636153 100644 --- a/tools/test/server.test.js +++ b/tools/test/server.test.js @@ -124,8 +124,8 @@ test('landing page links both tools', async () => { const res = await fetch(`${base}/`); const html = await res.text(); assert.equal(res.status, 200); - assert.match(html, /smart-pxa\.yrka\.app/); - assert.match(html, /\/fingerprint/); + assert.match(html, /href="\/cropper"/); + assert.match(html, /href="\/fingerprint"/); } finally { await stop(); } @@ -148,7 +148,7 @@ test('auth callback rejects unredeemable sessions without setting cookies', asyn const { base, stop } = await bootApp({}, { cookieStatus: 'TimedOut' }); try { const res = await fetch(`${base}/auth/callback?sessionId=s1`, manual); - assert.equal(res.status, 502); + assert.equal(res.status, 401); assert.equal(cookieValue(res, 'session'), undefined); const html = await res.text(); assert.match(html, /timed out/i);