openvibers/openvibe.mediahub Commits
OpenVibe.Pics on openvibe.pics: upload, share, albums, reports and takedown (#64)
Changes
@@@ -66,3 +66,19 @@ MEDIAHUB_LIVE_PUBLIC_URL=https://openvibe.liveMEDIAHUB_CHAT_URL=https://openvibe.live# IndexNow for openvibe.video (8–128 letters and digits): new watch pages are sent to search engines. Unset: off.#INDEXNOW_KEY=++++# ── OpenVibe.Pics (openvibe.pics, server/pics) ──++# Served by Host header on openvibe.pics. Pictures are private Media objects served through this process.++MEDIAHUB_PICS_URL=https://openvibe.pics++#MEDIAHUB_PICS_MAX_IMAGE_BYTES=20971520++#MEDIAHUB_PICS_MAX_PIXELS=100000000++#MEDIAHUB_PICS_QUOTA_BYTES=5368709120++#MEDIAHUB_PICS_IMAGES_PER_DAY=200++#MEDIAHUB_PICS_ALBUMS_MAX=200++#MEDIAHUB_PICS_ALBUM_IMAGES_MAX=500++#MEDIAHUB_PICS_REPORTS_TO_HIDE=3++# Signed-out uploads stay off (unset) until the DMCA designated agent and the CSAM hash-matching account exist.++#MEDIAHUB_PICS_ANONYMOUS=1++#MEDIAHUB_PICS_ANONYMOUS_MAX_IMAGE_BYTES=10485760++#MEDIAHUB_PICS_ANONYMOUS_PER_DAY=20++#MEDIAHUB_PICS_ANONYMOUS_IDLE_DAYS=30@@@ -2,9 +2,9 @@> Your files, shared on your terms.--**Status:** alpha. One process serves **openvibe.download** (a private drive, live in v1) and--**openvibe.video** (public watch pages and playlists for OpenVibe.Live's stream recordings and clips), chosen by Host--header. **openvibe.pics** is still parked: it redirects to `https://openvibe.network/` until Pics is built.++**Status:** alpha. One process serves **openvibe.download** (a private drive, live in v1), **openvibe.video** (public++watch pages and playlists for OpenVibe.Live's stream recordings and clips) and **openvibe.pics** (picture hosting,++2026-10-15), chosen by Host header.**Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB` · **License:** AGPL-3.0.## Purpose@@@ -13,8 +13,7 @@ OpenVibe.MediaHub is the network's file service: it serves **openvibe.download**signed-in person can upload, organise, share and delete their own files. The bytes live in OpenVibe.Media; MediaHubkeeps the metadata, the per-person limits, the share links and the report queue. It also answers OpenVibe.Network'saccount export and deletion events (ADR-033). The same process serves **openvibe.video**, the public watch pages for--OpenVibe.Live's stream recordings and clips (OpenVibe.Video v1, step A). openvibe.pics is parked in nginx and redirects--to openvibe.network.++OpenVibe.Live's stream recordings and clips (OpenVibe.Video v1, step A), and **openvibe.pics**, OpenVibe.Pics (below).## Owns@@@ -33,6 +32,10 @@ creates none):(openvibe-sdk/account-data, ADR-033).- `video_playlists` and `video_playlist_items` — the person's playlists and their ordered Live items(`migrations/0003_video_playlists.sql`); the items cascade from their playlist.++- `pics_images`, `pics_albums`, `pics_album_images`, `pics_reports`, `pics_blocklist`, `pics_usage`++ (`migrations/0006_pics.sql`) — OpenVibe.Pics: each picture (its Media object and thumbnail, size, hashes, state and++ report count; a signed-out upload's delete key only as its sha256), albums and their order, one report per reporter++ per picture, the takedown blocklist (sha256 and difference hashes) and uploads per uploader per day.- `telemetry_samples` (`migrations/0005_telemetry.sql`) — requests aggregated by route template, method and status class,never by raw URL; `/shared`, static assets, health, readiness, `/metrics` not counted; one `http.request` sample per key eachminute plus active-requests, p95 latency and event-loop-lag gauges (openvibe-sdk 0.46, `platform.telemetry-sample@1`); 14-day@@@ -46,8 +49,8 @@ creates none):`identity.subject.resolve`.- Event delivery, and the account export/deletion topics, are **OpenVibe.Events**': MediaHub only creates itssubscriptions and consumes the two deliveries.--- The **openvibe.pics** product (image hosting and albums) is not built here yet: that domain is parked in nginx.-- Previews and thumbnails are not built either.++- **CSAM hash-matching against known material** (PhotoDNA, NCMEC hash lists): that needs the owner's NCMEC / Cloudflare++ CSAM Scanning Tool account. Pics matches only its own takedown blocklist, and child-safety reports hide at once.## Depends on@@@ -122,6 +125,51 @@ calls Media server-side only, naming the person with `X-OV-Subject`, and never hMediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `mh_usage` (migrations/0002_mediahub.sql).++## OpenVibe.Pics (openvibe.pics)++++An open, ad-free picture host: drop a picture, get a link to share or embed. Built in [server/pics/](server/pics/).++++- **Upload:** a signed-in person uploads JPEG, PNG, GIF, WebP or AVIF (20 MB, 100 megapixels): the plain form at `/`++ works without JavaScript; [public/js/pics.js](public/js/pics.js) adds drag-and-drop, paste and several files at once++ through the API. Limits: 200 a day, 5 GB a person (`MEDIAHUB_PICS_*`).++- **Every picture is re-encoded** ([server/pics/images.js](server/pics/images.js), sharp/libvips): it must decode as++ one of those formats (a renamed script, an SVG or a truncated file is refused), the EXIF orientation is applied and++ every metadata block is dropped (GPS position, camera serial, XMP, IPTC), animations keep their frames, colour goes++ to sRGB, and a 640-pixel WebP thumbnail is made. Nothing is ever served as it was uploaded.++- **Storage and serving:** the picture and its thumbnail are **private** OpenVibe.Media objects (`MEDIAHUB_MEDIA_APP`).++ `/i/<slug>.<ext>` (the direct link) and `/i/<slug>/thumb.webp` read them from Media and serve them with `nosniff`, a++ sandboxing CSP, `Access-Control-Allow-Origin: *` and a 10-minute public cache, so the Media id never reaches a++ browser and a hidden picture leaves shared caches within minutes.++- **Unlisted:** `/i/<slug>` (8 random characters) is reachable by its link only, noindex, never listed; robots.txt++ disallows `/i/` and `/a/`. The page has the links to share and embed: the page, the direct link, Markdown, BBCode++ and HTML.++- **Albums** (`/albums`, `/a/<slug>`): a person's own, shown to anyone with the link (hidden pictures left out).++- **Reports and takedown:** anyone may report a picture once (signed out: counted by a keyed hash of the address).++ A child-safety report hides it at once; any other reason hides it at 3 distinct reports. Hidden is 404 for everyone++ but its uploader and staff. Staff (`/staff`, Network `admin`/`global_mod`) restore, hide, or take down: a takedown++ deletes the bytes in Media and puts the picture's sha256 (uploaded and served) and its 64-bit difference hash on++ `pics_blocklist`, so the same picture, or a resized, re-compressed or retouched copy (within 10 bits: measured on++ real photos, such copies land 0–10 bits away and different photos 23–42), cannot be uploaded again.++- **Signed out** (`MEDIAHUB_PICS_ANONYMOUS=1`, **off**): uploads up to 10 MB, 20 a day per address, each with a delete++ key shown once and stored only as its sha256; a picture nobody has viewed for 30 days is deleted by an hourly sweep.++ It stays off until the owner registers the DMCA designated agent and the CSAM hash-matching account (NCMEC /++ Cloudflare CSAM Scanning Tool): plan T10's gate.++- **Legal:** `/terms`, `/privacy` and `/dmca` name openvibe.pics (openvibe-shared/legal, the `ugc` profile).++++API on the pics host (`/api/v1`, a person; app, agent and service tokens are refused):++++| Route | |++|---|---|++| `POST /images` | upload: the picture is the body (`Content-Type: image/*`; optional `X-Title`, `X-Filename`, URI-encoded); 201 with the links |++| `GET /images/:slug` | metadata and links (hidden: uploader and staff only; 410 when taken down) |++| `PATCH /images/:slug` | the uploader: `{ title, description }` |++| `DELETE /images/:slug` | the uploader (signed out: `X-Delete-Key`) |++| `POST /images/:slug/reports` | anyone: `{ reason, note }`; `reason` one of `child_safety`, `illegal`, `copyright`, `sexual`, `violence`, `harassment`, `private_info`, `spam`, `other` |++| `GET /me/images` | your pictures, newest first, with your usage |++| `GET /albums`, `POST /albums` | your albums; create one `{ title, description }` |++| `GET /albums/:slug` | an album and its pictures |++| `POST /albums/:slug/images`, `DELETE /albums/:slug/images/:image`, `DELETE /albums/:slug` | the owner adds or takes out a picture, or deletes the album (the pictures stay) |++## OpenVibe.Video (openvibe.video)OpenVibe.Video is public and read-only: it serves watch pages for the recordings and clips people already made@@@ -279,14 +327,17 @@ are Live ids only; the watch pages resolve their display fields lazily through ttakes `network.account.export_requested` and `network.account.deleted`, and the subscriptions are created at boot(`MEDIAHUB_EVENTS_URL`).--- **Export:** the person's files, folders, shares, the reports they made and their usage.--- **Deletion:** all of it is erased, and each object is deleted in Media too.++- **Export:** the person's files, folders, shares, the reports they made and their usage; their playlists; and their++ OpenVibe.Pics pictures, albums (with their pictures' order), the reports they filed and their upload counts.++- **Deletion:** all of it is erased, and each object (a file, a picture and its thumbnail) is deleted in Media too.++ The takedown blocklist is staff's record, not the person's, and stays.## ConfigurationEvery name is in [.env.example](.env.example): the Network client (`OV_OAUTH_CLIENT_ID=media-hub`, secret, internalURL), PostgreSQL and Valkey (written by the host's data role), Media (`MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`),--Live (`MEDIAHUB_LIVE_URL`, `MEDIAHUB_LIVE_PUBLIC_URL`) and Video (`MEDIAHUB_VIDEO_URL`),++Live (`MEDIAHUB_LIVE_URL`, `MEDIAHUB_LIVE_PUBLIC_URL`), Video (`MEDIAHUB_VIDEO_URL`), Pics (`MEDIAHUB_PICS_URL` and the++`MEDIAHUB_PICS_*` limits, `MEDIAHUB_PICS_ANONYMOUS`),Events, and the limits (`MEDIAHUB_QUOTA_BYTES`, `MEDIAHUB_MAX_FILE_BYTES`,`MEDIAHUB_UPLOADS_PER_DAY`, `MEDIAHUB_MAX_SHARES`, `MEDIAHUB_SINGLE_UPLOAD_BYTES`, `MEDIAHUB_PART_BYTES`).@@@ -153,37 +153,110 @@ server {}}--# ── openvibe.pics: parked. It redirects to openvibe.network until Pics is built. ──--# No app is behind this host: every request is answered here with a 302, and nothing is proxied.++# ── openvibe.pics: OpenVibe.Pics, the same app as openvibe.download, chosen by Host header. ──server {listen 80;listen [::]:80;server_name openvibe.pics www.openvibe.pics;location /.well-known/acme-challenge/ { root /var/www/certbot; }-- location / { return 302 https://openvibe.network/; }++ location / { return 301 https://openvibe.pics$request_uri; }}server {listen 443 ssl;listen [::]:443 ssl;http2 on;-- server_name openvibe.pics;++ server_name www.openvibe.pics;++ssl_certificate /etc/letsencrypt/live/openvibe.pics/fullchain.pem;ssl_certificate_key /etc/letsencrypt/live/openvibe.pics/privkey.pem;ssl_protocols TLSv1.2 TLSv1.3;-- return 302 https://openvibe.network/;++++ return 301 https://openvibe.pics$request_uri;}server {listen 443 ssl;listen [::]:443 ssl;http2 on;-- server_name www.openvibe.pics;++ server_name openvibe.pics;++++ include snippets/security-txt.conf; # /.well-known/security.txt (host file; SECURITY.md in every repo)ssl_certificate /etc/letsencrypt/live/openvibe.pics/fullchain.pem;ssl_certificate_key /etc/letsencrypt/live/openvibe.pics/privkey.pem;ssl_protocols TLSv1.2 TLSv1.3;-- return 302 https://openvibe.network/;++++ access_log /var/log/nginx/openvibe.pics.access.log;++ error_log /var/log/nginx/openvibe.pics.error.log;++++ # Forms and the API's JSON are small; only the two upload paths below take a picture (20 MB, MEDIAHUB_PICS_MAX_IMAGE_BYTES).++ client_max_body_size 128k;++++ add_header X-Content-Type-Options nosniff always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ location = /auth/me {++ limit_req zone=media-hub_api burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4990;++ }++++ location /auth/ {++ limit_req zone=media-hub_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4990;++ }++++ # A picture upload: the API (JavaScript, one picture per request) and the plain form. Streamed to the app, which++ # re-encodes it; nothing is buffered to disk here.++ location = /api/v1/images {++ limit_req zone=media-hub_writes burst=20 nodelay;++ limit_req_status 429;++ client_max_body_size 21m;++ proxy_request_buffering off;++ proxy_pass http://127.0.0.1:4990;++ }++++ location = /upload {++ limit_req zone=media-hub_writes burst=10 nodelay;++ limit_req_status 429;++ client_max_body_size 21m;++ proxy_request_buffering off;++ proxy_pass http://127.0.0.1:4990;++ }++++ location /api/ {++ limit_req zone=media-hub_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4990;++ }++++ location ~* ^/internal(/|$) { return 404; }++ location ~* ^/metrics(/|$) { return 404; }++++ # The pages and the picture files (/i/<slug>.<ext>, served by the app with a 10-minute public cache).++ location / {++ proxy_pass http://127.0.0.1:4990;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }}# ── openvibe.video: OpenVibe.Video, the same app as openvibe.download, chosen by Host header. ──nnew file mode 100644@@@ -0,0 +1,85 @@++-- phase: expand++-- OpenVibe.Pics (server/pics): pictures on openvibe.pics, their albums, the reports people file and the hashes staff++-- took down. The bytes are OpenVibe.Media objects (unlisted: anyone with the link); MediaHub keeps what they are,++-- who uploaded them and whether they may be shown. Nothing here is a credential, except that a signed-out upload's++-- delete key is kept only as its sha256.++++CREATE TABLE pics_images (++ id text COLLATE "C" PRIMARY KEY, -- pic_<ULID>++ slug text COLLATE "C" NOT NULL UNIQUE, -- the 8-character name in /i/<slug>++ owner text COLLATE "C", -- user:usr_…, or NULL for a signed-out upload++ delete_key_hash text COLLATE "C", -- sha256 of a signed-out upload's delete key++ title text NOT NULL DEFAULT '', -- <= 120 characters (enforced in code)++ description text NOT NULL DEFAULT '', -- <= 2,000 characters (enforced in code)++ object_id text COLLATE "C" NOT NULL, -- med_… the picture, re-encoded without its metadata++ thumb_object_id text COLLATE "C", -- med_… a 640-pixel WebP of it (the first frame if animated)++ mime_type text COLLATE "C" NOT NULL,++ ext text COLLATE "C" NOT NULL, -- jpg | png | gif | webp | avif++ width integer NOT NULL,++ height integer NOT NULL,++ bytes bigint NOT NULL,++ animated boolean NOT NULL DEFAULT false,++ sha256 text COLLATE "C" NOT NULL, -- of the bytes served++ source_sha256 text COLLATE "C" NOT NULL, -- of the bytes uploaded++ dhash text COLLATE "C" NOT NULL, -- 64-bit difference hash, 16 hex digits (near-duplicates)++ state text COLLATE "C" NOT NULL DEFAULT 'active',++ report_count integer NOT NULL DEFAULT 0,++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ last_viewed_at text COLLATE "C",++ CHECK (state IN ('active', 'hidden', 'removed'))++);++CREATE INDEX pics_images_owner ON pics_images (owner, id DESC);++CREATE INDEX pics_images_state ON pics_images (state, id DESC);++CREATE INDEX pics_images_anonymous_idle ON pics_images (last_viewed_at) WHERE owner IS NULL;++++CREATE TABLE pics_albums (++ id text COLLATE "C" PRIMARY KEY, -- alb_<ULID>++ slug text COLLATE "C" NOT NULL UNIQUE, -- /a/<slug>++ owner text COLLATE "C" NOT NULL, -- user:usr_… (albums are account-only)++ title text NOT NULL,++ description text NOT NULL DEFAULT '',++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL++);++CREATE INDEX pics_albums_owner ON pics_albums (owner, id DESC);++++CREATE TABLE pics_album_images (++ album_id text COLLATE "C" NOT NULL REFERENCES pics_albums (id) ON DELETE CASCADE,++ image_id text COLLATE "C" NOT NULL REFERENCES pics_images (id) ON DELETE CASCADE,++ position integer NOT NULL,++ added_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (album_id, image_id)++);++CREATE INDEX pics_album_images_order ON pics_album_images (album_id, position);++++-- One report per reporter per picture. A signed-out reporter is a keyed hash of their address, never the address.++CREATE TABLE pics_reports (++ id text COLLATE "C" PRIMARY KEY, -- prp_<ULID>++ image_id text COLLATE "C" NOT NULL REFERENCES pics_images (id) ON DELETE CASCADE,++ reporter text COLLATE "C" NOT NULL,++ reason text COLLATE "C" NOT NULL,++ note text NOT NULL DEFAULT '',++ created_at text COLLATE "C" NOT NULL,++ UNIQUE (image_id, reporter)++);++++-- What staff took down stays down: an upload whose bytes (sha256) or near-duplicate (dhash) match is refused.++CREATE TABLE pics_blocklist (++ kind text COLLATE "C" NOT NULL, -- sha256 | dhash++ value text COLLATE "C" NOT NULL,++ reason text COLLATE "C" NOT NULL,++ image_id text COLLATE "C", -- the picture it came from (kept after it is gone)++ created_by text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (kind, value),++ CHECK (kind IN ('sha256', 'dhash'))++);++++-- Uploads per uploader per UTC day: a person, or a keyed hash of a signed-out address.++CREATE TABLE pics_usage (++ uploader text COLLATE "C" NOT NULL,++ day text COLLATE "C" NOT NULL,++ uploads integer NOT NULL DEFAULT 0,++ PRIMARY KEY (uploader, day)++);@@@ -18,7 +18,8 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "sharp": "^0.33.5"},"devDependencies": {"@electric-sql/pglite": "^0.5.8"@@@ -34,6 +35,413 @@"devOptional": true,"license": "Apache-2.0"},++ "node_modules/@emnapi/runtime": {++ "version": "1.11.3",++ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",++ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",++ "license": "MIT",++ "optional": true,++ "dependencies": {++ "tslib": "^2.4.0"++ }++ },++ "node_modules/@img/sharp-darwin-arm64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",++ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",++ "cpu": [++ "arm64"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "darwin"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-darwin-arm64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-darwin-x64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",++ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",++ "cpu": [++ "x64"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "darwin"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-darwin-x64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-libvips-darwin-arm64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",++ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",++ "cpu": [++ "arm64"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "darwin"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-darwin-x64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",++ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",++ "cpu": [++ "x64"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "darwin"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linux-arm": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",++ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",++ "cpu": [++ "arm"++ ],++ "libc": [++ "glibc"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linux-arm64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",++ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",++ "cpu": [++ "arm64"++ ],++ "libc": [++ "glibc"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linux-s390x": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",++ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",++ "cpu": [++ "s390x"++ ],++ "libc": [++ "glibc"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linux-x64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",++ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",++ "cpu": [++ "x64"++ ],++ "libc": [++ "glibc"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",++ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",++ "cpu": [++ "arm64"++ ],++ "libc": [++ "musl"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linuxmusl-x64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",++ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",++ "cpu": [++ "x64"++ ],++ "libc": [++ "musl"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-linux-arm": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",++ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",++ "cpu": [++ "arm"++ ],++ "libc": [++ "glibc"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linux-arm": "1.0.5"++ }++ },++ "node_modules/@img/sharp-linux-arm64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",++ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",++ "cpu": [++ "arm64"++ ],++ "libc": [++ "glibc"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linux-arm64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-linux-s390x": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",++ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",++ "cpu": [++ "s390x"++ ],++ "libc": [++ "glibc"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linux-s390x": "1.0.4"++ }++ },++ "node_modules/@img/sharp-linux-x64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",++ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",++ "cpu": [++ "x64"++ ],++ "libc": [++ "glibc"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linux-x64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-linuxmusl-arm64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",++ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",++ "cpu": [++ "arm64"++ ],++ "libc": [++ "musl"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-linuxmusl-x64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",++ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",++ "cpu": [++ "x64"++ ],++ "libc": [++ "musl"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-wasm32": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",++ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",++ "cpu": [++ "wasm32"++ ],++ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",++ "optional": true,++ "dependencies": {++ "@emnapi/runtime": "^1.2.0"++ },++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-win32-ia32": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",++ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",++ "cpu": [++ "ia32"++ ],++ "license": "Apache-2.0 AND LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "win32"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-win32-x64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",++ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",++ "cpu": [++ "x64"++ ],++ "license": "Apache-2.0 AND LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "win32"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },"node_modules/@iovalkey/commands": {"version": "0.1.0","resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",@@@ -169,6 +577,47 @@"node": ">=0.10.0"}},++ "node_modules/color": {++ "version": "4.2.3",++ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",++ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",++ "license": "MIT",++ "dependencies": {++ "color-convert": "^2.0.1",++ "color-string": "^1.9.0"++ },++ "engines": {++ "node": ">=12.5.0"++ }++ },++ "node_modules/color-convert": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",++ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",++ "license": "MIT",++ "dependencies": {++ "color-name": "~1.1.4"++ },++ "engines": {++ "node": ">=7.0.0"++ }++ },++ "node_modules/color-name": {++ "version": "1.1.4",++ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",++ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",++ "license": "MIT"++ },++ "node_modules/color-string": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",++ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",++ "license": "MIT",++ "dependencies": {++ "color-name": "^1.0.0",++ "simple-swizzle": "^0.2.2"++ }++ },"node_modules/content-disposition": {"version": "0.5.4","resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",@@@ -255,6 +704,15 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/detect-libc": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=8"++ }++ },"node_modules/dotenv": {"version": "16.6.1","resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",@@@ -653,6 +1111,12 @@"node": ">= 0.10"}},++ "node_modules/is-arrayish": {++ "version": "0.3.4",++ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",++ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",++ "license": "MIT"++ },"node_modules/json-schema-traverse": {"version": "1.0.0","resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",@@@ -1242,6 +1706,45 @@"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==","license": "ISC"},++ "node_modules/sharp": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",++ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",++ "hasInstallScript": true,++ "license": "Apache-2.0",++ "dependencies": {++ "color": "^4.2.3",++ "detect-libc": "^2.0.3",++ "semver": "^7.6.3"++ },++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-darwin-arm64": "0.33.5",++ "@img/sharp-darwin-x64": "0.33.5",++ "@img/sharp-libvips-darwin-arm64": "1.0.4",++ "@img/sharp-libvips-darwin-x64": "1.0.4",++ "@img/sharp-libvips-linux-arm": "1.0.5",++ "@img/sharp-libvips-linux-arm64": "1.0.4",++ "@img/sharp-libvips-linux-s390x": "1.0.4",++ "@img/sharp-libvips-linux-x64": "1.0.4",++ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",++ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",++ "@img/sharp-linux-arm": "0.33.5",++ "@img/sharp-linux-arm64": "0.33.5",++ "@img/sharp-linux-s390x": "0.33.5",++ "@img/sharp-linux-x64": "0.33.5",++ "@img/sharp-linuxmusl-arm64": "0.33.5",++ "@img/sharp-linuxmusl-x64": "0.33.5",++ "@img/sharp-wasm32": "0.33.5",++ "@img/sharp-win32-ia32": "0.33.5",++ "@img/sharp-win32-x64": "0.33.5"++ }++ },"node_modules/side-channel": {"version": "1.1.1","resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",@@@ -1314,6 +1817,15 @@"url": "https://github.com/sponsors/ljharb"}},++ "node_modules/simple-swizzle": {++ "version": "0.2.4",++ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",++ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",++ "license": "MIT",++ "dependencies": {++ "is-arrayish": "^0.3.1"++ }++ },"node_modules/split2": {"version": "4.2.0","resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",@@@ -1347,6 +1859,13 @@"node": ">=0.6"}},++ "node_modules/tslib": {++ "version": "2.8.1",++ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",++ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",++ "license": "0BSD",++ "optional": true++ },"node_modules/type-is": {"version": "1.6.18","resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",@@@ -25,7 +25,8 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "sharp": "^0.33.5"},"engines": {"node": ">=22.0.0"nnew file mode 100644@@@ -0,0 +1,138 @@++/* OpenVibe.Pics — openvibe.pics (server/pics/pages.js, ovp-*). Loaded after app.css (the drive's base: body type, links,++ .page, .notice); colours come from the network theme variables, with the Pics pink as the brand accent. Filled++ buttons are pink with near-black text (about 8:1), never white on pink. */++++.ovp-page {++ --ovp-pink: #f472b6;++ --ovp-pink-soft: #f9a8d4;++ --ovp-on-pink: #1f0a17;++ --ovp-accent-text: color-mix(in srgb, var(--ovp-pink) 72%, var(--text-primary, #e6edf7));++ --ovp-card: var(--bg-card, var(--bg-secondary, #101828));++ --ovp-line: var(--border, #1f2d47);++ --ovp-line-strong: var(--border-light, #2c3d5c);++ --ovp-dim: var(--text-secondary, #96a7c2);++ --ovp-faint: var(--text-muted, #7386a3);++ max-width: 1120px;++ padding: 24px 24px 72px;++}++.ovp-page h2 { font-size: 1.15rem; margin: 0 0 .75rem; }++++/* Buttons */++.ovp-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 38px; padding: 7px 14px; border-radius: 10px; border: 1px solid var(--ovp-line-strong); background: var(--bg-tertiary, #16223a); color: var(--text-primary, #e6edf7) !important; font: inherit; font-size: .9rem; font-weight: 700; text-decoration: none; cursor: pointer; }++.ovp-btn:hover, .ovp-btn:focus-visible { border-color: var(--ovp-pink); }++.ovp-btn--primary { background: var(--ovp-pink); border-color: var(--ovp-pink); color: var(--ovp-on-pink) !important; }++.ovp-btn--primary:hover, .ovp-btn--primary:focus-visible { background: var(--ovp-pink-soft); border-color: var(--ovp-pink-soft); }++.ovp-btn--danger { background: transparent; border-color: color-mix(in srgb, var(--error, #ef4444) 55%, transparent); color: var(--error, #ef4444) !important; }++.ovp-btn--danger:hover, .ovp-btn--danger:focus-visible { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: var(--error, #ef4444); }++++/* Home */++.ovp-hero { padding: 12px 0 18px; }++.ovp-eyebrow { display: inline-block; margin: 0; padding: 4px 12px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--ovp-pink) 45%, var(--ovp-line-strong)); color: var(--ovp-accent-text); font-size: .78rem; font-weight: 700; letter-spacing: .3px; }++.ovp-hero h1 { margin: .6rem 0 .5rem; font-size: clamp(1.9rem, 4.6vw, 3rem); line-height: 1.08; letter-spacing: -.02em; }++.ovp-accent { background: linear-gradient(92deg, var(--ovp-pink), #c084fc 70%); -webkit-background-clip: text; background-clip: text; color: transparent; }++.ovp-lede { margin: 0; max-width: 62ch; font-size: 1.05rem; color: var(--ovp-dim); }++++/* The drop zone: a plain form without JavaScript; public/js/pics.js adds drag, paste and many files. */++.ovp-drop { position: relative; margin: 6px 0 0; border: 2px dashed color-mix(in srgb, var(--ovp-pink) 55%, var(--ovp-line-strong)); border-radius: 20px; background: radial-gradient(120% 140% at 50% 0%, color-mix(in srgb, var(--ovp-pink) 10%, transparent), transparent 60%), var(--ovp-card); transition: border-color .15s ease, background-color .15s ease; }++.ovp-drop.is-dragover { border-color: var(--ovp-pink); border-style: solid; background-color: color-mix(in srgb, var(--ovp-pink) 10%, var(--ovp-card)); }++.ovp-drop-inner { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 36px 20px 30px; text-align: center; }++.ovp-drop--compact .ovp-drop-inner { padding: 20px 16px; }++.ovp-drop--compact .ovp-drop-icon, .ovp-drop--compact .ovp-drop-hint { display: none; }++.ovp-drop-icon { color: var(--ovp-pink); opacity: .9; }++.ovp-drop-title { margin: 0; font-size: 1.15rem; font-weight: 700; }++.ovp-drop-hint { margin: 0; max-width: 52ch; color: var(--ovp-dim); font-size: .92rem; }++.ovp-file { position: relative; overflow: hidden; }++.ovp-file input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; font-size: 0; }++.ovp-file:focus-within { outline: 3px solid var(--ovp-pink-soft); outline-offset: 2px; }++.ovp-drop-fields { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; font-size: .9rem; color: var(--ovp-dim); }++.ovp-drop.is-enhanced .ovp-nojs-submit, .ovp-drop.is-enhanced .ovp-drop-fields { display: none; }++.ovp-drop--signin .ovp-drop-inner { padding: 30px 20px; }++++/* Upload results (the script) */++.ovp-results { list-style: none; margin: 0; padding: 0 16px; display: grid; gap: 10px; }++.ovp-results:not(:empty) { padding-bottom: 16px; }++.ovp-result { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 12px; align-items: center; padding: 10px; border-radius: 14px; border: 1px solid var(--ovp-line); background: var(--bg-secondary, #0f1729); text-align: left; }++.ovp-result img, .ovp-result .ovp-result-ph { width: 64px; height: 64px; border-radius: 10px; object-fit: cover; background: var(--bg-tertiary, #16223a); }++.ovp-result-main { min-width: 0; display: grid; gap: 6px; }++.ovp-result-name { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovp-result-error { color: var(--error, #ef4444); font-size: .9rem; }++.ovp-progress { height: 6px; border-radius: 999px; background: var(--bg-tertiary, #16223a); overflow: hidden; }++.ovp-progress span { display: block; height: 100%; width: 0; background: var(--ovp-pink); transition: width .15s linear; }++.ovp-result .ovp-copy-row input { font-size: .82rem; }++++/* Sections, grids */++.ovp-section { margin-top: 2.2rem; }++.ovp-section-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 16px; margin: 0 0 .9rem; }++.ovp-section-head h1, .ovp-section-head h2 { margin: 0; }++.ovp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; }++.ovp-tile { position: relative; display: block; aspect-ratio: 1; overflow: hidden; border-radius: 12px; border: 1px solid var(--ovp-line); background: var(--bg-tertiary, #16223a); }++.ovp-tile img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .2s ease; }++.ovp-tile:hover img, .ovp-tile:focus-visible img { transform: scale(1.03); }++.ovp-tile.is-hidden img { opacity: .45; }++.ovp-flag { position: absolute; left: 8px; top: 8px; padding: 2px 8px; border-radius: 999px; background: rgba(0, 0, 0, .7); color: #fff; font-size: .72rem; font-weight: 700; }++.ovp-features { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }++.ovp-feature { padding: 16px 18px; border-radius: 14px; border: 1px solid var(--ovp-line); background: var(--ovp-card); }++.ovp-feature h3 { margin: 0 0 6px; font-size: 1rem; }++.ovp-feature p { margin: 0; color: var(--ovp-dim); font-size: .92rem; }++++/* A picture */++.ovp-picture { margin: 0; }++.ovp-title { margin: 0 0 12px; font-size: clamp(1.4rem, 3vw, 2rem); overflow-wrap: anywhere; }++.ovp-figure { margin: 0; display: flex; justify-content: center; border-radius: 16px; overflow: hidden; border: 1px solid var(--ovp-line);++ background-color: var(--bg-tertiary, #16223a);++ background-image: linear-gradient(45deg, rgba(255,255,255,.04) 25%, transparent 25%, transparent 75%, rgba(255,255,255,.04) 75%), linear-gradient(45deg, rgba(255,255,255,.04) 25%, transparent 25%, transparent 75%, rgba(255,255,255,.04) 75%);++ background-size: 24px 24px; background-position: 0 0, 12px 12px; }++.ovp-figure a { display: flex; max-width: 100%; }++.ovp-figure img { display: block; max-width: 100%; max-height: 78vh; width: auto; height: auto; object-fit: contain; }++.ovp-meta { margin: 10px 0 0; color: var(--ovp-faint); font-size: .88rem; }++.ovp-desc { max-width: 70ch; white-space: pre-line; overflow-wrap: anywhere; }++.ovp-share { margin-top: 1.6rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 10px 16px; }++.ovp-share h2 { grid-column: 1 / -1; margin-bottom: 0; }++.ovp-copy { display: grid; gap: 4px; min-width: 0; }++.ovp-copy label { font-size: .8rem; font-weight: 700; color: var(--ovp-dim); }++.ovp-copy-row { display: flex; gap: 6px; min-width: 0; }++.ovp-copy-row input { flex: 1; min-width: 0; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .85rem; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--ovp-line-strong); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }++.ovp-copy-row .ovp-btn { min-width: 74px; }++.ovp-page > .ovp-copy { max-width: 640px; margin: 0 0 14px; }++.ovp-key { display: inline-block; margin-top: 6px; overflow-wrap: anywhere; }++.ovp-confirm-thumb { max-width: 240px; border-radius: 12px; }++++/* Forms */++.ovp-form { display: grid; gap: 6px; max-width: 640px; margin: 0 0 14px; }++.ovp-form label, .ovp-form legend { font-size: .85rem; font-weight: 700; color: var(--ovp-dim); }++.ovp-page input[type=text], .ovp-page textarea, .ovp-page select { font: inherit; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--ovp-line-strong); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); max-width: 100%; }++.ovp-page textarea { resize: vertical; }++.ovp-inline { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; max-width: none; }++.ovp-inline label { width: 100%; }++.ovp-owner { padding: 16px 18px; border-radius: 16px; border: 1px solid var(--ovp-line); background: var(--ovp-card); }++.ovp-report { margin-top: 1.6rem; }++.ovp-report summary { cursor: pointer; color: var(--ovp-faint); font-size: .9rem; width: fit-content; }++.ovp-report fieldset { border: 0; padding: 0; margin: 8px 0; display: grid; gap: 4px; }++.ovp-radio { font-weight: 400 !important; color: var(--text-primary, #e6edf7) !important; display: flex; gap: 8px; align-items: center; }++++/* Albums */++.ovp-albums { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; margin-top: 1rem; }++.ovp-album { display: grid; gap: 4px; padding: 8px; border-radius: 14px; border: 1px solid var(--ovp-line); background: var(--ovp-card); text-decoration: none; color: inherit; }++.ovp-album:hover, .ovp-album:focus-visible { border-color: var(--ovp-pink); }++.ovp-album img, .ovp-album-empty { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 10px; background: var(--bg-tertiary, #16223a); }++.ovp-album-title { font-weight: 700; padding: 2px 4px 0; overflow-wrap: anywhere; }++.ovp-album .muted { padding: 0 4px; font-size: .85rem; }++++/* Staff */++.ovp-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 1rem; }++.ovp-tab { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--ovp-line-strong); text-decoration: none; color: inherit; font-size: .9rem; }++.ovp-tab.on { border-color: var(--ovp-pink); font-weight: 700; }++.ovp-staff { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 14px; padding: 12px 0; border-top: 1px solid var(--ovp-line); }++.ovp-staff img, .ovp-staff .ovp-album-empty { width: 120px; aspect-ratio: 1; object-fit: cover; border-radius: 10px; }++.ovp-staff p { margin: 0 0 6px; }++++@media (max-width: 640px) {++ .ovp-page { padding: 16px 16px 56px; }++ .ovp-grid { grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 6px; }++ .ovp-tile { border-radius: 8px; }++ .ovp-share { grid-template-columns: minmax(0, 1fr); }++ .ovp-staff { grid-template-columns: 72px minmax(0, 1fr); }++ .ovp-staff img, .ovp-staff .ovp-album-empty { width: 72px; }++ .ovp-drop-inner { padding: 28px 14px 24px; }++}++@media (prefers-reduced-motion: reduce) { .ovp-tile img, .ovp-drop { transition: none; } .ovp-tile:hover img { transform: none; } }nnew file mode 100644@@@ -0,0 +1,148 @@++/*++ * OpenVibe.Pics on top of the plain forms (server/pics/pages.js): drop or paste pictures, several at once, each sent++ * to POST /api/v1/images with a progress bar, then its links to copy. Copy buttons ([data-copy]) and confirmations++ * ([data-confirm]) anywhere on the page. Nothing here is needed: without it every form still works.++ * DOM nodes only, never innerHTML.++ */++(function () {++ 'use strict';++++ function el(tag, props, children) {++ var node = document.createElement(tag);++ Object.keys(props || {}).forEach(function (k) {++ if (k === 'text') node.textContent = props[k];++ else if (k === 'className') node.className = props[k];++ else node.setAttribute(k, props[k]);++ });++ (children || []).forEach(function (c) { if (c) node.appendChild(c); });++ return node;++ }++++ function copyFrom(input, button) {++ var done = function () { button.textContent = 'Copied'; setTimeout(function () { button.textContent = 'Copy'; }, 1600); };++ if (navigator.clipboard && navigator.clipboard.writeText) {++ navigator.clipboard.writeText(input.value).then(done, function () { input.select(); });++ } else {++ input.select();++ try { document.execCommand('copy'); done(); } catch (e) { /* the text is selected; the person copies it */ }++ }++ }++++ document.addEventListener('click', function (ev) {++ var btn = ev.target.closest('[data-copy]');++ if (!btn) return;++ var input = document.getElementById(btn.getAttribute('data-copy'));++ if (input) copyFrom(input, btn);++ });++ document.addEventListener('submit', function (ev) {++ var msg = ev.target.getAttribute && ev.target.getAttribute('data-confirm');++ if (msg && !window.confirm(msg)) ev.preventDefault();++ });++++ var form = document.querySelector('[data-pics-upload]');++ if (!form || !window.XMLHttpRequest || !window.FormData) return;++ var input = form.querySelector('[data-pics-input]');++ var results = form.querySelector('[data-pics-results]');++ var max = Number(form.getAttribute('data-max')) || 0;++ var TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];++ var queue = [];++ var busy = false;++ var seq = 0;++++ form.classList.add('is-enhanced');++ input.multiple = true;++ input.required = false;++++ function copyRow(label, value) {++ var id = 'up-' + (++seq);++ var field = el('input', { id: id, type: 'text', readonly: '', spellcheck: 'false', 'aria-label': label });++ field.value = value;++ return el('div', { className: 'ovp-copy-row' }, [field, el('button', { className: 'ovp-btn', type: 'button', 'data-copy': id, text: 'Copy' })]);++ }++++ function row(file) {++ var bar = el('span');++ var main = el('div', { className: 'ovp-result-main' }, [el('div', { className: 'ovp-result-name', text: file.name || 'Pasted picture' }), el('div', { className: 'ovp-progress', role: 'progressbar', 'aria-label': 'Uploading' }, [bar])]);++ var img = el('img', { alt: '' });++ try { img.src = URL.createObjectURL(file); } catch (e) { img = el('span', { className: 'ovp-result-ph' }); }++ var li = el('li', { className: 'ovp-result' }, [img, main]);++ results.prepend(li);++ return { li: li, main: main, bar: bar };++ }++++ function fail(r, text) {++ var p = r.main.querySelector('.ovp-progress');++ if (p) p.remove();++ r.main.appendChild(el('div', { className: 'ovp-result-error', text: text }));++ }++++ function send(file, r) {++ return new Promise(function (resolve) {++ var xhr = new XMLHttpRequest();++ xhr.open('POST', '/api/v1/images');++ xhr.setRequestHeader('Content-Type', file.type);++ xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name || 'pasted'));++ xhr.upload.onprogress = function (e) { if (e.lengthComputable) r.bar.style.width = Math.round(e.loaded / e.total * 100) + '%'; };++ xhr.onload = function () {++ var body = null;++ try { body = JSON.parse(xhr.responseText); } catch (e) { body = null; }++ if (xhr.status === 201 && body && body.links) {++ var p = r.main.querySelector('.ovp-progress');++ if (p) p.remove();++ var name = r.main.querySelector('.ovp-result-name');++ name.textContent = '';++ name.appendChild(el('a', { href: body.links.page, text: body.title || 'Your picture' }));++ r.main.appendChild(copyRow('Link to the picture’s page', body.links.page));++ r.main.appendChild(copyRow('Direct link', body.links.direct));++ if (body.delete_url) r.main.appendChild(el('div', { className: 'ovp-result-error', text: 'Keep this delete link, shown only now: ' + body.delete_url }));++ } else if (xhr.status === 401) {++ fail(r, 'Sign in with OpenVibe to upload.');++ } else {++ fail(r, (body && body.detail) || 'That picture could not be uploaded. Try again.');++ }++ resolve();++ };++ xhr.onerror = function () { fail(r, 'The connection dropped. Try again.'); resolve(); };++ xhr.send(file);++ });++ }++++ function pump() {++ if (busy || !queue.length) return;++ busy = true;++ var job = queue.shift();++ send(job.file, job.r).then(function () { busy = false; pump(); });++ }++++ function add(files) {++ Array.prototype.forEach.call(files, function (file) {++ var r = row(file);++ if (TYPES.indexOf(file.type) === -1) return fail(r, 'Pictures can be JPEG, PNG, GIF, WebP or AVIF.');++ if (max && file.size > max) return fail(r, 'This picture is larger than ' + Math.round(max / 1048576) + ' MB.');++ queue.push({ file: file, r: r });++ return undefined;++ });++ pump();++ }++++ input.addEventListener('change', function () { if (input.files && input.files.length) add(input.files); input.value = ''; });++ form.addEventListener('submit', function (ev) { ev.preventDefault(); if (input.files && input.files.length) add(input.files); });++++ var depth = 0;++ document.addEventListener('dragenter', function (e) { if (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types || [], 'Files') !== -1) { depth++; form.classList.add('is-dragover'); } });++ document.addEventListener('dragleave', function () { depth = Math.max(0, depth - 1); if (!depth) form.classList.remove('is-dragover'); });++ document.addEventListener('dragover', function (e) { if (form.classList.contains('is-dragover')) e.preventDefault(); });++ document.addEventListener('drop', function (e) {++ if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;++ e.preventDefault();++ depth = 0;++ form.classList.remove('is-dragover');++ add(e.dataTransfer.files);++ });++ document.addEventListener('paste', function (e) {++ var t = e.target;++ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA') && t.type !== 'file') return;++ var items = (e.clipboardData && e.clipboardData.files) || [];++ if (items.length) { e.preventDefault(); add(items); }++ });++})();@@@ -3,7 +3,8 @@/*** This service's share of a person's account export and deletion (ADR-033), through openvibe-sdk/account-data.*-- * network.account.export_requested the person's files, folders, shares, reports and upload counts, as JSON++ * network.account.export_requested the person's files, folders, shares, reports and upload counts, playlists, and++ * their OpenVibe.Pics pictures, albums, reports and upload counts, as JSON* network.account.deleted the same rows erased, and every Media object they held deleted** The events arrive signed on POST /internal/events (loopback only). `apply` is wrapped here for one reason: an@@@ -35,6 +36,13 @@ const TABLES = [// An upload that never finished is this service's own state, not something the person made: it is erased with// the account but is not part of the export.{ table: 'mh_uploads', subject: 'owner', value: AS_REQUESTER, file: null },++ // OpenVibe.Pics (migrations/0006_pics.sql). The reports a person filed go first; the reports other people filed++ // on their pictures, and the album places, cascade with the pictures and albums (exported with extraExport).++ // pics_blocklist is staff's record of what was taken down, not the person's data, so it stays.++ { table: 'pics_reports', subject: 'reporter', value: AS_REQUESTER, file: 'pics_reports.json' },++ { table: 'pics_albums', subject: 'owner', value: AS_REQUESTER, file: 'pics_albums.json' },++ { table: 'pics_images', subject: 'owner', value: AS_REQUESTER, file: 'pics_images.json' },++ { table: 'pics_usage', subject: 'uploader', value: AS_REQUESTER, file: 'pics_usage.json' },];function createAccount({ db, config, media, log = console }) {@@@ -45,7 +53,7 @@ function createAccount({ db, config, media, log = console }) {db,service: 'media-hub',tables: TABLES,-- note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts and your playlists. The bytes of your files live in OpenVibe.Media.',++ note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts and your playlists, and your OpenVibe.Pics pictures and albums. The bytes of your files and pictures live in OpenVibe.Media.',extraExport: async (db, subject) => {const rows = await db.many(`SELECT i.playlist_id, i.kind, i.item_id, i.position, i.added_at@@@ -53,7 +61,17 @@ function createAccount({ db, config, media, log = console }) {JOIN video_playlists p ON p.id = i.playlist_idWHERE p.owner = $1ORDER BY i.playlist_id, i.position ASC`, [`user:${subject}`]);-- return rows.length ? [{ name: 'playlist_items.json', content: rows }] : [];++ const albumRows = await db.many(++ `SELECT ai.album_id, a.slug AS album_slug, i.slug AS image_slug, ai.position, ai.added_at++ FROM pics_album_images ai++ JOIN pics_albums a ON a.id = ai.album_id++ JOIN pics_images i ON i.id = ai.image_id++ WHERE a.owner = $1++ ORDER BY ai.album_id, ai.position ASC`, [`user:${subject}`]);++ return [++ ...(rows.length ? [{ name: 'playlist_items.json', content: rows }] : []),++ ...(albumRows.length ? [{ name: 'pics_album_images.json', content: albumRows }] : []),++ ];},extraErase: async () => {const ids = pendingObjects;@@@ -78,8 +96,11 @@ function createAccount({ db, config, media, log = console }) {if (ev && ev.event_type === 'network.account.deleted' && ev.source === 'network') {const payload = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};// Read the media objects first: the erase deletes the rows that name them.-- pendingObjects = (await db.many('SELECT object_id FROM mh_files WHERE owner = ANY($1::text[])', [subjectsOf(payload)]))-- .map((r) => r.object_id).filter(Boolean);++ const subjects = subjectsOf(payload);++ pendingObjects = [++ ...(await db.many('SELECT object_id FROM mh_files WHERE owner = ANY($1::text[])', [subjects])).map((r) => r.object_id),++ ...(await db.many('SELECT object_id, thumb_object_id FROM pics_images WHERE owner = ANY($1::text[])', [subjects])).flatMap((r) => [r.object_id, r.thumb_object_id]),++ ].filter(Boolean);}return await accountData.apply(ev, { send });}@@@ -29,6 +29,8 @@ const { createPrincipal } = require('./http/principal');const { createApi } = require('./http/api');const { createPageRoutes } = require('./http/pages');const { createVideoRoutes } = require('./video/pages');++const { createPicsRoutes } = require('./pics/pages');++const { createPics } = require('./pics/service');const { createServiceReadiness } = require('./observability');const { createCallerLimits } = require('./http/caller-limits');const { assetVersion, send } = require('./render/layout');@@@ -67,7 +69,8 @@ async function createApp(opts = {}) {const uploads = createUploads({ s, media, config, service: drive, log });const account = createAccount({ db: s.db, config, media, log });const playlists = createPlaylists({ s, config, log });-- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, fetchImpl, log };++ const pics = createPics({ s, media, config, log });++ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, fetchImpl, log };// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.ctx.indexnow = opts.indexnow || (() => {const warn = (...a) => (log.warn || console.warn)(...a);@@@ -142,14 +145,17 @@ async function createApp(opts = {}) {// origin's /release.json, and so does the host's browser watch.release.mount(app, { registry: metrics.registry });-- // ── Host routing: openvibe.video is OpenVibe.Video; every other host (openvibe.download) stays-- // the drive. On the video host the drive's pages, API and legal pages never answer.++ // ── Host routing: openvibe.video is OpenVibe.Video, openvibe.pics is OpenVibe.Pics; every other host++ // (openvibe.download) stays the drive. On those hosts the drive's pages, API and legal pages never answer.const videoHost = (() => { try { return new URL(config.video.url).hostname; } catch { return null; } })();++ const picsHost = (() => { try { return new URL(config.pics.url).hostname; } catch { return null; } })();const videoRoutes = createVideoRoutes(ctx);++ const picsRoutes = createPicsRoutes(ctx);// GET /<key>.txt on the video host: IndexNow's key file, only when a key is configured.if (ctx.indexnow.enabled) app.use((req, res, next) => (videoHost && req.hostname === videoHost ? ctx.indexnow.keyFile(req, res, next) : next()));app.use((req, res, next) => {if (videoHost && req.hostname === videoHost) return videoRoutes(req, res, next);++ if (picsHost && req.hostname === picsHost) return picsRoutes(req, res, next);return next();});@@@ -59,9 +59,14 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat// host-only (no Domain), so each host keeps its own session.const videoRedirectUri = (() => { try { return `${String(config.video.url).replace(/\/+$/, '')}/auth/callback`; } catch { return null; } })();const videoHostname = (() => { try { return new URL(config.video.url).hostname; } catch { return null; } })();-- const redirectUriFor = (req) => (videoRedirectUri && videoHostname && req && req.hostname === videoHostname-- ? videoRedirectUri-- : config.oauth.redirectUri);++ // openvibe.pics likewise (https://openvibe.pics/auth/callback, registered for client media-hub on 2026-10-15).++ const picsRedirectUri = (() => { try { return `${String(config.pics.url).replace(/\/+$/, '')}/auth/callback`; } catch { return null; } })();++ const picsHostname = (() => { try { return new URL(config.pics.url).hostname; } catch { return null; } })();++ const redirectUriFor = (req) => {++ if (videoRedirectUri && videoHostname && req && req.hostname === videoHostname) return videoRedirectUri;++ if (picsRedirectUri && picsHostname && req && req.hostname === picsHostname) return picsRedirectUri;++ return config.oauth.redirectUri;++ };function setSession(res, data) {res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });@@@ -71,6 +71,27 @@ function load(env = process.env) {indexnowKey: String(env.INDEXNOW_KEY || '').trim(),},++ // OpenVibe.Pics (server/pics): picture hosting on openvibe.pics, chosen by Host header like the video host.++ // The bytes are private Media objects, re-encoded here with their metadata stripped and served only through++ // this service (/i/<slug>.<ext>). Signed-out uploads stay off until the DMCA agent and the CSAM++ // hash-matching account exist (plan T10 gate, owner items O25 and the CSAM tool account): `anonymous` is++ // MEDIAHUB_PICS_ANONYMOUS=1 then.++ pics: {++ url: trim(env.MEDIAHUB_PICS_URL || 'https://openvibe.pics'),++ maxImageBytes: size(env.MEDIAHUB_PICS_MAX_IMAGE_BYTES, 20 * MB),++ maxPixels: size(env.MEDIAHUB_PICS_MAX_PIXELS, 100_000_000),++ quotaBytes: size(env.MEDIAHUB_PICS_QUOTA_BYTES, 5 * GB),++ imagesPerDay: size(env.MEDIAHUB_PICS_IMAGES_PER_DAY, 200),++ albumsMax: size(env.MEDIAHUB_PICS_ALBUMS_MAX, 200),++ albumImagesMax: size(env.MEDIAHUB_PICS_ALBUM_IMAGES_MAX, 500),++ reportsToHide: Math.max(1, size(env.MEDIAHUB_PICS_REPORTS_TO_HIDE, 3)),++ anonymous: env.MEDIAHUB_PICS_ANONYMOUS === '1',++ anonymousMaxImageBytes: size(env.MEDIAHUB_PICS_ANONYMOUS_MAX_IMAGE_BYTES, 10 * MB),++ anonymousPerDay: size(env.MEDIAHUB_PICS_ANONYMOUS_PER_DAY, 20),++ // A signed-out upload is deleted after this many days without a view.++ anonymousIdleDays: Math.max(1, size(env.MEDIAHUB_PICS_ANONYMOUS_IDLE_DAYS, 30)),++ },++// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one// tenant), so the per-person numbers are enforced in MediaHub and nowhere else.quotas: {@@@ -47,6 +47,11 @@ const BUDGETS = {'media-hub.share.create': { minute: 20, hour: 200 },'media-hub.playlist.create': { minute: 30, hour: 300 },'media-hub.playlist.write': { minute: 120, hour: 1_200 },++ // OpenVibe.Pics (server/pics): a burst of pictures dropped at once, but not a script emptying a disk into it.++ 'pics.image.upload': { minute: 30, hour: 300 },++ 'pics.image.write': { minute: 60, hour: 600 },++ 'pics.album.create': { minute: 10, hour: 100 },++ 'pics.report.create': { minute: 5, hour: 30 },};function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {@@@ -43,7 +43,15 @@ async function start() {const { createLiveClient } = require('./video/live-client');const videoIndexNow = createVideoIndexNow({ config, live: createLiveClient({ config, fetchImpl: ctx.fetchImpl, log: ctx.log }), indexnow: ctx.indexnow, log: ctx.log });videoIndexNow.start();-- createLifecycle({ server, ctx, extra: [() => videoIndexNow.stop(), () => { if (subscriptions) subscriptions.stop(); }] });++ // OpenVibe.Pics: signed-out pictures nobody has viewed for MEDIAHUB_PICS_ANONYMOUS_IDLE_DAYS go, hourly (only while++ // signed-out uploads are on; account pictures stay until their owner deletes them).++ const timers = [];++ if (config.pics.anonymous) {++ const sweep = setInterval(() => { ctx.pics.sweep().then((n) => { if (n) console.log(`[OpenVibe.Pics] deleted ${n} idle signed-out picture(s)`); }).catch((err) => console.warn('[OpenVibe.Pics] sweep failed:', err && err.message)); }, 3_600_000);++ sweep.unref();++ timers.push(sweep);++ }++ createLifecycle({ server, ctx, timers, extra: [() => videoIndexNow.stop(), () => { if (subscriptions) subscriptions.stop(); }] });return { server, ctx };}@@@ -5,7 +5,7 @@* Network service token (audience `openvibe.media`), and never hands that token, an upload token or a signed URL* to a browser.*-- * init({ size, contentType, name, ownerSubject, multipart }) POST /api/v2/media-hub/objects++ * init({ size, contentType, name, ownerSubject, multipart, visibility, ownerless }) POST /api/v2/media-hub/objects* putContent(objectId, buffer) PUT …/objects/:id/content* complete(objectId) POST …/objects/:id/complete* multipartStatus(objectId, uploadId) GET …/objects/:id/multipart/:uploadId@@@ -84,14 +84,16 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = consoleenabled,/** Start an object. Media gives it back `uploading` until its bytes arrive and it is completed. */-- async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null }) {++ async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null, visibility = 'private', ownerless = false }) {++ // A drive file always belongs to a person. A picture uploaded signed out (Pics, `ownerless`) is the++ // service's own object, with no subject on it.const subject = subjectHeader(ownerSubject);-- if (!subject) return { ok: false, status: 400, code: 'file.bad_owner', detail: 'a file belongs to a person with a canonical subject' };++ if (!subject && !ownerless) return { ok: false, status: 400, code: 'file.bad_owner', detail: 'a file belongs to a person with a canonical subject' };return await call('', {method: 'POST',-- headers: { 'X-OV-Subject': subject },++ headers: subject ? { 'X-OV-Subject': subject } : {},json: {-- kind: 'file', visibility: 'private', size_bytes: size,++ kind: 'file', visibility: ['private', 'unlisted', 'public'].includes(visibility) ? visibility : 'private', size_bytes: size,// Media only needs a label: the real name (any script) is MediaHub's, and Media's own download// header takes a filename as is, so it gets the printable-ASCII rendering.mime_type: contentType || null, filename: headerSafeName(name), multipart: multipart === true,nnew file mode 100644@@@ -0,0 +1,183 @@++'use strict';++++/**++ * /api/v1 on openvibe.pics — OpenVibe.Pics' API. A person is a Network token as a Bearer, or this site's session (a++ * session write must come from openvibe.pics itself). App, agent and service tokens are refused: a picture is++ * somebody's own. Signed-out uploads answer only when MEDIAHUB_PICS_ANONYMOUS=1.++ *++ * POST /images person upload: the picture is the body (Content-Type image/*);++ * X-Title and X-Filename headers (URI-encoded) are optional++ * GET /images/:slug anyone a picture's metadata and links (hidden: uploader and staff only)++ * PATCH /images/:slug uploader { title, description }++ * DELETE /images/:slug uploader (signed out: the X-Delete-Key header from the upload)++ * POST /images/:slug/reports anyone { reason, note }++ * GET /me/images?before= person your pictures, newest first++ * GET /albums person your albums++ * POST /albums person { title, description }++ * GET /albums/:slug anyone an album and its pictures++ * POST /albums/:slug/images owner { image: slug }++ * DELETE /albums/:slug/images/:image owner++ * DELETE /albums/:slug owner (the pictures stay)++ *++ * Refusals are RFC 9457 problem+json with a stable code (pics.*).++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { asyncRouter } = require('../http/router');++const { sameOrigin } = require('../http/principal');++const store = require('./store');++++function createPicsApi(ctx) {++ const { config, s, principal, limits } = ctx;++ const pics = ctx.pics;++ const cfg = config.pics;++ const r = asyncRouter();++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });++++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ r.use(principal.middleware);++++ const whoOf = (req) => (req.principal.kind === 'user'++ ? { requester: req.principal.requester, staff: pics.isStaff({ kind: 'user', role: req.principal.role || (req.principal.claims && req.principal.claims.role) }), ip: req.ip }++ : { ip: req.ip });++++ /** A person may act; signed out only where `anonymousOk`; app tokens never. Session writes come from this site. */++ function gate({ anonymousOk = false } = {}) {++ return (req, res, next) => {++ const p = req.principal;++ if (p.kind !== 'user' && p.kind !== 'anonymous') return problem(req, res, 403, 'pics.person_required', 'Pictures belong to a person; an app, agent or service token cannot upload or change them.');++ if (p.kind === 'anonymous' && !anonymousOk) return problem(req, res, 401, 'token.required', 'Sign in at openvibe.pics, or send a person\'s Network token as a Bearer.');++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, cfg.url)) return problem(req, res, 403, 'request.cross_site', 'A signed-in request that changes something must come from openvibe.pics itself.');++ return next();++ };++ }++ const person = gate();++ const anyone = gate({ anonymousOk: true });++ const header = (req, name, max) => { try { return decodeURIComponent(String(req.get(name) || '')).slice(0, max); } catch { return ''; } };++++ async function load(req, res) {++ const slug = String(req.params.slug || '');++ const row = pics.isSlug(slug) ? await store.imageBySlug(s, slug) : null;++ if (!row) { problem(req, res, 404, 'pics.not_found', 'No such picture.'); return null; }++ if (row.state === 'removed') { problem(req, res, 410, 'pics.taken_down', 'This picture was taken down.'); return null; }++ if (!pics.canSee(row, whoOf(req))) { problem(req, res, 404, 'pics.not_found', 'No such picture.'); return null; }++ return row;++ }++ async function loadOwn(req, res) {++ const row = await load(req, res);++ if (!row) return null;++ if (!pics.isOwner(row, whoOf(req))) { problem(req, res, 403, 'pics.not_yours', 'Only the person who uploaded this picture can change it.'); return null; }++ return row;++ }++++ // ── Upload: the raw picture is the body ─────────────────++ const rawBody = express.raw({ type: () => true, limit: Math.max(cfg.maxImageBytes, cfg.anonymousMaxImageBytes) + 1 });++ r.post('/images', anyone, limits.budget('pics.image.upload'), (req, res, next) => rawBody(req, res, (err) => {++ if (err && err.type === 'entity.too.large') return problem(req, res, 413, 'pics.too_large', `A picture can be up to ${Math.round(cfg.maxImageBytes / 1024 / 1024)} MB.`);++ return next(err);++ }), async (req, res) => {++ if (req.principal.kind === 'anonymous' && !cfg.anonymous) return problem(req, res, 401, 'pics.sign_in_required', 'Sign in with OpenVibe to upload pictures.');++ if (!/^image\//i.test(String(req.get('content-type') || ''))) return problem(req, res, 415, 'pics.not_an_image', 'Send the picture as the body, with its image/* Content-Type.');++ const out = await pics.upload(whoOf(req), { buffer: Buffer.isBuffer(req.body) ? req.body : null, name: header(req, 'x-filename', 200) }, { title: header(req, 'x-title', 200) });++ if (!out.ok) return problem(req, res, out.status, out.code, out.detail);++ res.set('Location', `/api/v1/images/${out.image.slug}`);++ return res.status(201).json({ ...pics.toWire(out.image, { owner: true }), ...(out.deleteKey ? { delete_key: out.deleteKey, delete_url: `${cfg.url}/i/${out.image.slug}/delete?key=${out.deleteKey}` } : {}) });++ });++++ r.use(express.json({ limit: '64kb' }));++++ r.get('/images/:slug', async (req, res) => {++ const row = await load(req, res);++ if (!row) return undefined;++ return res.json(pics.toWire(row, { owner: pics.isOwner(row, whoOf(req)) }));++ });++++ r.patch('/images/:slug', person, limits.budget('pics.image.write'), async (req, res) => {++ const row = await loadOwn(req, res);++ if (!row) return undefined;++ const b = req.body || {};++ return res.json(pics.toWire(await pics.edit(row, { title: b.title, description: b.description }), { owner: true }));++ });++++ r.delete('/images/:slug', anyone, limits.budget('pics.image.write'), async (req, res) => {++ const row = await load(req, res);++ if (!row) return undefined;++ if (!(pics.isOwner(row, whoOf(req)) || pics.deleteKeyMatches(row, String(req.get('x-delete-key') || '')))) return problem(req, res, 403, 'pics.not_yours', 'Only the person who uploaded this picture can delete it.');++ await pics.remove(row);++ return res.status(204).end();++ });++++ r.post('/images/:slug/reports', anyone, limits.budget('pics.report.create'), async (req, res) => {++ const row = await load(req, res);++ if (!row) return undefined;++ const who = whoOf(req);++ const b = req.body || {};++ const out = await pics.report(row, { reporter: who.requester || pics.addressId(who.ip), reason: String(b.reason || ''), note: b.note });++ if (!out.ok) return problem(req, res, out.status, out.code, out.detail);++ return res.status(out.added ? 201 : 200).json({ reported: true, already: !out.added, hidden: out.hidden });++ });++++ r.get('/me/images', person, async (req, res) => {++ const before = /^pic_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.query.before || '')) ? String(req.query.before) : null;++ const limit = Math.min(100, Math.max(1, Number.parseInt(req.query.limit, 10) || 48));++ const out = await store.imagesOf(s, whoOf(req).requester, { limit, before });++ return res.json({ images: out.rows.map((x) => pics.toWire(x, { owner: true })), next: out.next, usage: await store.usageOf(s, whoOf(req).requester), quota_bytes: cfg.quotaBytes });++ });++++ // ── Albums ──────────────────────────────────────────────++ const albumWire = (a, images) => ({ id: a.id, slug: a.slug, title: a.title, description: a.description, url: `${cfg.url}/a/${a.slug}`, created_at: a.created_at, updated_at: a.updated_at, ...(images ? { images } : { image_count: Number(a.image_count || 0) }) });++ async function ownAlbum(req, res) {++ const slug = String(req.params.slug || '');++ const a = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;++ if (!a || a.owner !== whoOf(req).requester) { problem(req, res, 404, 'pics.album_not_found', 'No such album of yours.'); return null; }++ return a;++ }++++ r.get('/albums', person, async (req, res) => res.json({ albums: (await store.albumsOf(s, whoOf(req).requester)).map((a) => albumWire(a)) }));++++ r.post('/albums', person, limits.budget('pics.album.create'), async (req, res) => {++ const out = await pics.createAlbum(whoOf(req).requester, req.body || {});++ if (!out.ok) return problem(req, res, out.status, out.code, out.detail);++ res.set('Location', `/api/v1/albums/${out.album.slug}`);++ return res.status(201).json(albumWire(out.album, []));++ });++++ r.get('/albums/:slug', async (req, res) => {++ const slug = String(req.params.slug || '');++ const a = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;++ if (!a) return problem(req, res, 404, 'pics.album_not_found', 'No such album.');++ const owner = a.owner === whoOf(req).requester;++ return res.json(albumWire(a, (await store.albumImages(s, a.id, { visibleOnly: !owner })).map((x) => pics.toWire(x, { owner }))));++ });++++ r.post('/albums/:slug/images', person, limits.budget('pics.image.write'), async (req, res) => {++ const a = await ownAlbum(req, res);++ if (!a) return undefined;++ const imgSlug = String((req.body || {}).image || '');++ const img = pics.isSlug(imgSlug) ? await store.imageBySlug(s, imgSlug) : null;++ if (!img || !pics.isOwner(img, whoOf(req)) || img.state === 'removed') return problem(req, res, 422, 'pics.not_yours', 'image: the slug of one of your pictures');++ const out = await pics.addToAlbum(a, img);++ if (!out.ok) return problem(req, res, out.status, out.code, out.detail);++ return res.status(204).end();++ });++++ r.delete('/albums/:slug/images/:image', person, limits.budget('pics.image.write'), async (req, res) => {++ const a = await ownAlbum(req, res);++ if (!a) return undefined;++ const img = pics.isSlug(req.params.image) ? await store.imageBySlug(s, String(req.params.image)) : null;++ if (img) await store.removeFromAlbum(s, a.id, img.id);++ return res.status(204).end();++ });++++ r.delete('/albums/:slug', person, limits.budget('pics.image.write'), async (req, res) => {++ const a = await ownAlbum(req, res);++ if (!a) return undefined;++ await store.deleteAlbum(s, a.id);++ return res.status(204).end();++ });++++ r.use((req, res) => problem(req, res, 404, 'route.not_found', 'No such API route on openvibe.pics.'));++ return r;++}++++module.exports = { createPicsApi };nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++++/**++ * What happens to a picture before anyone sees it (sharp, libvips):++ *++ * - It must decode as JPEG, PNG, GIF, WebP or AVIF, within MEDIAHUB_PICS_MAX_PIXELS. Anything else — a script++ * renamed .png, an SVG, a truncated or malformed file — is refused. Nothing is served as it was uploaded.++ * - It is re-encoded in its own format: the orientation is applied, and every metadata block (EXIF with its GPS++ * position and camera serial, XMP, IPTC, comments) is dropped. An animated GIF or WebP keeps its frames.++ * A colour profile is converted to sRGB rather than kept.++ * - A 640-pixel WebP thumbnail is made (the first frame of an animation).++ * - Three hashes: sha256 of what was uploaded and of what is served, and a 64-bit difference hash (dHash) of a++ * 9×8 greyscale copy, which stays close for a resized, re-compressed or lightly edited copy of the same picture.++ * The takedown blocklist matches on all three (server/pics/service.js).++ *++ * prepare(buffer) → { ok: true, image: { buffer, mime, ext, width, height, animated }, thumb: { buffer, mime,++ * width, height }, sha256, sourceSha256, dhash } or { ok: false, code, detail }.++ */++const crypto = require('crypto');++const sharp = require('sharp');++++// libvips caches decoded images; this process serves pages too, so keep its memory small and its threads few.++sharp.cache({ memory: 64, files: 0, items: 50 });++sharp.concurrency(2);++++const FORMATS = {++ jpeg: { mime: 'image/jpeg', ext: 'jpg' },++ png: { mime: 'image/png', ext: 'png' },++ gif: { mime: 'image/gif', ext: 'gif' },++ webp: { mime: 'image/webp', ext: 'webp' },++ heif: { mime: 'image/avif', ext: 'avif' }, // sharp reports AVIF as heif; only the AV1 kind decodes++};++const EXTS = Object.values(FORMATS).map((f) => f.ext);++const THUMB = 640;++++const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');++++/** The 64-bit difference hash of a picture's first frame, as 16 hex digits. */++async function dhashOf(input) {++ const px = await sharp(input, { pages: 1, failOn: 'error' }).rotate().grayscale().resize(9, 8, { fit: 'fill', kernel: 'cubic' }).raw().toBuffer();++ let bits = 0n;++ for (let y = 0; y < 8; y++) {++ // A margin of 2 levels: in a flat area two neighbours differ by compression noise only, and a bare `>` would++ // flip with every re-encode.++ for (let x = 0; x < 8; x++) bits = (bits << 1n) | (px[y * 9 + x] - px[y * 9 + x + 1] > 2 ? 1n : 0n);++ }++ return bits.toString(16).padStart(16, '0');++}++++/** How many of the 64 bits two difference hashes disagree on. */++function distance(a, b) {++ let x = BigInt(`0x${a}`) ^ BigInt(`0x${b}`);++ let n = 0;++ while (x) { n += Number(x & 1n); x >>= 1n; }++ return n;++}++++async function prepare(input, { maxPixels = 100_000_000 } = {}) {++ if (!Buffer.isBuffer(input) || input.length < 16) return { ok: false, code: 'pics.not_an_image', detail: 'That file is not a picture we can show.' };++ let meta;++ try {++ meta = await sharp(input, { limitInputPixels: maxPixels, failOn: 'error', animated: true }).metadata();++ } catch (err) {++ const tooBig = /pixel limit/i.test(String(err && err.message));++ return tooBig++ ? { ok: false, code: 'pics.too_many_pixels', detail: `That picture has more than ${Number((maxPixels / 1e6).toPrecision(3))} megapixels.` }++ : { ok: false, code: 'pics.not_an_image', detail: 'That file is not a JPEG, PNG, GIF, WebP or AVIF picture we can read.' };++ }++ const format = FORMATS[meta.format];++ if (!format || (meta.format === 'heif' && meta.compression !== 'av1')) {++ return { ok: false, code: 'pics.unsupported', detail: 'Pictures can be JPEG, PNG, GIF, WebP or AVIF.' };++ }++ const pages = Number(meta.pages) || 1;++ const animated = pages > 1 && (meta.format === 'gif' || meta.format === 'webp');++ try {++ // .rotate() with no angle applies the EXIF orientation; libvips drops all metadata on output unless asked.++ let pipe = sharp(input, { limitInputPixels: maxPixels, failOn: 'error', animated });++ if (!animated) pipe = pipe.rotate();++ pipe = pipe.toColourspace('srgb');++ if (meta.format === 'jpeg') pipe = pipe.jpeg({ quality: 88, mozjpeg: true, chromaSubsampling: '4:2:0' });++ else if (meta.format === 'png') pipe = pipe.png({ compressionLevel: 9, adaptiveFiltering: true });++ else if (meta.format === 'gif') pipe = pipe.gif({ effort: 7 });++ else if (meta.format === 'webp') pipe = pipe.webp({ quality: 90, effort: 4 });++ else pipe = pipe.avif({ quality: 62, effort: 4 });++ const { data, info } = await pipe.toBuffer({ resolveWithObject: true });++ const height = animated ? Math.round(info.height / (info.pages || pages)) : info.height;++++ const thumb = await sharp(data, { pages: 1, failOn: 'error' })++ .resize(THUMB, THUMB, { fit: 'inside', withoutEnlargement: true })++ .webp({ quality: 76, effort: 4 })++ .toBuffer({ resolveWithObject: true });++++ return {++ ok: true,++ image: { buffer: data, mime: format.mime, ext: format.ext, width: info.width, height, animated },++ thumb: { buffer: thumb.data, mime: 'image/webp', width: thumb.info.width, height: thumb.info.height },++ sha256: sha256(data),++ sourceSha256: sha256(input),++ dhash: await dhashOf(data),++ };++ } catch {++ return { ok: false, code: 'pics.not_an_image', detail: 'That picture could not be read to the end. Is the file complete?' };++ }++}++++module.exports = { prepare, dhashOf, distance, sha256, EXTS, FORMATS, THUMB };nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++++/**++ * The OpenVibe.Pics page shell. Same composition as the video host's (openvibe-shared/shell, the OpenVibe Frame++ * navbar and footer, the shared app icon); every canonical URL is on the pics host, and the brand is OpenVibe.Pics.++ *++ * A page rendered for a signed-in person names them (and a session cookie is present), so it is++ * private and never cached; every other page stays public. Cloudflare ignores Vary, so the cache++ * decision is made from the session cookie's presence rather than Vary.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const appIcon = require('openvibe-shared/app-icon');++const shell = require('openvibe-shared/shell');++const cache = require('openvibe-shared/cache-policy');++const { html, raw, esc, render } = require('../render/html');++++const NETWORK_URL = 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Pics';++const TAGLINE = 'Share pictures. Fast, open, yours.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++++const hashes = new Map();++function assetVersion(rel) {++ if (hashes.has(rel)) return hashes.get(rel);++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }++ hashes.set(rel, v);++ return v;++}++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;++++/** o: title, description, body (html), baseUrl, path, index, noindex, viewer, private, crumbs [{label, href}], scripts [rel],++ * jsonLd (structured data for the page), image (its share image, an https URL), ogType */++function renderPage(o) {++ // The front page's title says what the site is (search results show it; at most 60 characters).++ const title = o.title || (o.path === '/' ? `${SITE_NAME}: share pictures, fast and open` : SITE_NAME);++ const viewer = o.viewer || { kind: 'anonymous' };++ const signedIn = viewer.kind === 'user';++ const pathNow = o.path || '/';++ const canonical = `${String(o.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}`;++ const crumbs = (o.crumbs || []).length++ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`++ : '';++ const head = `<meta name="referrer" content="strict-origin-when-cross-origin">++${appIcon.headTags({ site: 'media-hub' })}++<link rel="stylesheet" href="${asset('css/app.css')}">++<link rel="stylesheet" href="${asset('css/pics.css')}">++${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;++ const account = signedIn++ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${encodeURIComponent(pathNow)}">Sign out</a>`++ : html`<a href="/auth/login?next=${encodeURIComponent(pathNow)}">Sign in with OpenVibe</a>`;++ const body = `<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>++<main id="main" class="page ovp-page">++${render(crumbs)}++${render(o.body)}++</main>`;++ return shell.page({++ name: SITE_NAME,++ lang: 'en',++ title,++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',++ siteName: SITE_NAME,++ description: o.description || `${SITE_NAME} — ${TAGLINE}`,++ canonical,++ robots: o.noindex ? 'noindex' : (o.index ? 'index, follow' : 'noindex, nofollow'),++ ...(o.jsonLd ? { jsonLd: o.jsonLd } : {}),++ ...(o.image ? { image: o.image, imageAlt: title } : {}),++ ...(o.ogType ? { type: o.ogType } : {}),++ home: '/',++ navLinks: NAV_LINKS,++ navbar: {++ service: 'media-hub',++ apiBase: NETWORK_URL,++ links: NAV_LINKS.map((l) => ({ ...l, active: l.href === '/' ? o.path === '/' : String(o.path || '').startsWith(l.href) })),++ history: { type: 'page', title },++ sessionUrl: '/auth/me',++ loginUrl: '/auth/login?next={path}',++ logoutUrl: '/auth/logout?next={path}',++ silentLogin: `${String(o.baseUrl).replace(/\/+$/, '')}/auth/login?silent=1&next={url}`,++ },++ footer: { service: 'media-hub', variant: 'compact', brandName: SITE_NAME, updates: '/updates' },++ head,++ body,++ });++}++++// The site's own places in the navbar: upload, your pictures and your albums (those two ask you to sign in).++const NAV_LINKS = [{ label: 'Upload', href: '/' }, { label: 'Your pictures', href: '/mine' }, { label: 'Albums', href: '/albums' }];++++/** Send a Pics page. Signed-out pages are public; a session cookie makes a page private, no-store. */++function send(res, status, o) {++ if (!res.get('Cache-Control')) res.set('Cache-Control', o.cache || cache.htmlHeaders({ private: o.private, maxAge: 300 }));++ res.set('Vary', 'Host');++ res.status(status).type('html').send(renderPage(o));++}++++module.exports = { renderPage, send, asset, assetVersion, SITE_NAME, TAGLINE, raw, html };nnew file mode 100644@@@ -0,0 +1,501 @@++'use strict';++++/**++ * openvibe.pics — OpenVibe.Pics, served by this process for Host: openvibe.pics (server/app.js). Every page works++ * without JavaScript; public/js/pics.js adds drag-and-drop, paste and several files at once on top of the same API.++ *++ * / upload (signed in; signed out too when MEDIAHUB_PICS_ANONYMOUS=1) and your latest pictures++ * /i/<slug> a picture: the links to share and embed, edit, add to an album, delete, report++ * /i/<slug>.<ext> the picture itself (what the links embed), read from Media and served from here++ * /i/<slug>/thumb.webp its 640-pixel thumbnail++ * /mine, /albums your pictures and albums; /a/<slug> an album++ * /staff the report queue (Network admin and global_mod): restore, hide, take down++ * /api/v1/… the API (server/pics/api.js)++ * /robots.txt /llms.txt /terms /privacy /dmca /updates++ *++ * Pictures are unlisted: a page or a file is reachable by its link and never listed or indexed. A hidden picture is++ * 404 for everyone but its uploader and staff; a removed one is 410. The picture files are served with++ * `nosniff`, a sandboxing CSP and a 10-minute public cache, so a hidden picture leaves caches within minutes.++ */++const crypto = require('crypto');++const express = require('express');++const fs = require('fs');++const os = require('os');++const path = require('path');++const cache = require('openvibe-shared/cache-policy');++const seo = require('openvibe-shared/seo');++const ovServe = require('openvibe-shared/serve');++const frame = require('openvibe-shared/frame');++const { asyncRouter } = require('../http/router');++const { sameOrigin } = require('../http/principal');++const { boundaryOf, readFormData } = require('../http/form-upload');++const { formatBytes } = require('../drive/rules');++const { html, raw, notice, time } = require('../render/html');++const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');++const { send, assetVersion, SITE_NAME } = require('./layout');++const store = require('./store');++const { createPicsApi } = require('./api');++++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++const FILE_CACHE = 'public, max-age=600, s-maxage=600';++const KIND = { jpg: 'JPEG', png: 'PNG', gif: 'GIF', webp: 'WebP', avif: 'AVIF' };++++function createPicsRoutes(ctx) {++ const { config, s, media, log } = ctx;++ const pics = ctx.pics;++ const cfg = config.pics;++ const base = cfg.url;++ const host = (() => { try { return new URL(base).hostname; } catch { return 'openvibe.pics'; } })();++ const r = asyncRouter();++++ const hasSessionCookie = (req) => Boolean(req.cookies && (req.cookies[ACCESS_COOKIE] || req.cookies[REFRESH_COOKIE]));++ const page = (req, res, o, status = 200) => send(res, status, {++ ...o, viewer: req.viewer, private: o.private !== false && hasSessionCookie(req), baseUrl: base, path: req.originalUrl,++ });++ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);++ /** Who is asking, as the service wants it: { requester, staff } for a person, { ip } signed out. */++ const whoOf = (req) => (signedIn(req) ? { requester: `user:${req.viewer.subject}`, staff: pics.isStaff(req.viewer), ip: req.ip } : { ip: req.ip });++ const signInLink = (next, text = 'Sign in with OpenVibe') => html`<a class="ovp-btn ovp-btn--primary" href="/auth/login?next=${encodeURIComponent(next)}">${text}</a>`;++ const notFound = (req, res, status = 404) => page(req, res, {++ title: status === 410 ? 'Taken down' : 'Not found', cache: cache.htmlHeaders({ private: true }),++ body: status === 410++ ? html`<h1>This picture was taken down</h1><p class="muted">It broke the <a href="/terms">rules</a> or the law and is no longer on OpenVibe.Pics.</p>`++ : html`<h1>Not found</h1><p class="muted">No picture or page here. <a href="/">Upload one</a>.</p>`,++ }, status);++ const mustSignIn = (why) => (req, res, next) => (signedIn(req) ? next() : page(req, res, {++ title: 'Sign in', body: html`<h1>Sign in first</h1><p>${why}</p><p>${signInLink(req.originalUrl)}</p>`,++ }, 401));++ function sameSite(req, res, back) {++ if (sameOrigin(req, base)) return true;++ page(req, res, { title: 'Not from this site', body: html`<h1>That came from somewhere else</h1><p class="muted">A form that changes something has to come from ${host} itself. <a href="${back}">Go back</a> and send it again.</p>` }, 403);++ return false;++ }++ /** The per-caller budget the API uses, with a page of its own for a refusal (as http/pages.js budgeted()). */++ const budgeted = (name) => {++ const limit = ctx.limits.budget(name);++ return (req, res, next) => {++ let settled = false;++ limit(req, {++ statusCode: 0, setHeader: () => {}, getHeader: () => undefined, removeHeader: () => {},++ end: () => {++ if (settled) return;++ settled = true;++ page(req, res, { title: 'Too many requests', body: html`<h1>Too many requests just now</h1><p class="muted">Wait a minute and send it again.</p>` }, 429);++ },++ }, (err) => { if (settled) return undefined; settled = true; return err ? next(err) : next(); });++ return undefined;++ };++ };++++ // ── The shell's /shared copy and this site's stylesheet and script (public/) ──++ r.use('/shared', ovServe.handler());++ r.use(express.static(PUBLIC_DIR, {++ index: false, redirect: false,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));++ },++ }));++++ // ── The API on this host ────────────────────────────────++ r.use('/api/v1', createPicsApi(ctx));++ r.use('/api', (req, res) => res.status(404).type('application/problem+json').send(JSON.stringify({ type: 'about:blank', title: 'Not found', status: 404, code: 'route.not_found' })));++++ // ── The files ───────────────────────────────────────────++ async function serveObject(req, res, row, objectId, type) {++ const got = await media.bytes(objectId);++ if (!got.ok) return res.status(got.status === 404 || got.status === 410 ? 404 : 502).set('Cache-Control', 'no-store').type('text/plain').send(got.status === 404 || got.status === 410 ? 'Not found' : 'OpenVibe.Media did not answer.');++ const up = got.res;++ res.status(200).set({++ 'Content-Type': type,++ 'X-Content-Type-Options': 'nosniff',++ 'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",++ 'Cross-Origin-Resource-Policy': 'cross-origin',++ 'Access-Control-Allow-Origin': '*',++ 'X-Robots-Tag': 'noindex',++ // Hidden pictures leave shared caches within ten minutes; their uploader and staff get no-store copies.++ 'Cache-Control': row.state === 'active' ? FILE_CACHE : 'private, no-store',++ 'Content-Disposition': `inline; filename="${row.slug}.${row.ext}"`,++ ETag: `"${row.sha256.slice(0, 32)}"`,++ });++ const len = up.headers.get('content-length');++ if (len) res.set('Content-Length', len);++ if (req.method === 'HEAD' || !up.body) return res.end();++ const { Readable } = require('stream');++ Readable.fromWeb(up.body).on('error', () => res.destroy()).pipe(res);++ if (row.state === 'active') pics.viewed(row).catch(() => {});++ return undefined;++ }++++ async function picture(req, res) {++ const slug = String(req.params.slug || '');++ if (!pics.isSlug(slug)) return { row: null, status: 404 };++ const row = await store.imageBySlug(s, slug);++ if (!row) return { row: null, status: 404 };++ if (row.state === 'removed') return { row, status: 410 };++ if (!pics.canSee(row, whoOf(req))) return { row: null, status: 404 };++ return { row, status: 200 };++ }++++ r.get(/^\/i\/([2-9a-km-zA-HJ-NP-Z]{8})\.(jpg|png|gif|webp|avif)$/, async (req, res) => {++ req.params.slug = req.params[0];++ const { row, status } = await picture(req, res);++ if (!row || status !== 200 || row.ext !== req.params[1]) return res.status(status === 410 ? 410 : 404).set('Cache-Control', 'public, max-age=60').type('text/plain').send(status === 410 ? 'Taken down' : 'Not found');++ if (req.get('if-none-match') === `"${row.sha256.slice(0, 32)}"` && row.state === 'active') return res.status(304).set('Cache-Control', FILE_CACHE).end();++ return serveObject(req, res, row, row.object_id, row.mime_type);++ });++++ r.get('/i/:slug/thumb.webp', async (req, res) => {++ const { row, status } = await picture(req, res);++ if (!row || status !== 200) return res.status(status === 410 ? 410 : 404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('Not found');++ return row.thumb_object_id ? serveObject(req, res, row, row.thumb_object_id, 'image/webp') : res.redirect(302, `/i/${row.slug}.${row.ext}`);++ });++++ // ── Pages: forms are urlencoded, uploads multipart ──────++ r.use(express.urlencoded({ extended: false, limit: '64kb' }));++++ const thumb = (row) => html`<a class="ovp-tile${row.state !== 'active' ? ' is-hidden' : ''}" href="/i/${row.slug}"><img src="/i/${row.slug}/thumb.webp" alt="${row.title || 'Picture'}" loading="lazy" decoding="async" width="${Math.min(Number(row.width), 640)}" height="${Math.round(Math.min(Number(row.width), 640) * Number(row.height) / Math.max(1, Number(row.width)))}">${row.state !== 'active' ? html`<span class="ovp-flag">hidden</span>` : ''}</a>`;++ const grid = (rows, empty) => (rows.length ? html`<div class="ovp-grid">${rows.map(thumb)}</div>` : html`<p class="muted">${empty}</p>`);++++ function uploadForm(req, { compact = false } = {}) {++ const max = signedIn(req) ? cfg.maxImageBytes : cfg.anonymousMaxImageBytes;++ return html`<form class="ovp-drop${compact ? ' ovp-drop--compact' : ''}" method="post" action="/upload" enctype="multipart/form-data" data-pics-upload data-max="${max}">++<div class="ovp-drop-inner">++<span class="ovp-drop-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="40" height="40"><path fill="currentColor" d="M19 5v14H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Zm-4.86 8.86-3 3.87L9 13.14 6 17h12l-3.86-5.14Z"/></svg></span>++<p class="ovp-drop-title">Drop pictures here, paste one, or choose files</p>++<p class="ovp-drop-hint">JPEG, PNG, GIF, WebP or AVIF, up to ${formatBytes(max)} each. Location and camera details are removed.</p>++<label class="ovp-btn ovp-btn--primary ovp-file"><input type="file" name="file" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" required data-pics-input> Choose a picture</label>++<div class="ovp-drop-fields"><label for="up-title">Title (optional)</label><input id="up-title" name="title" type="text" maxlength="120" placeholder="What is it?"></div>++<button class="ovp-btn ovp-btn--primary ovp-nojs-submit" type="submit">Upload</button>++</div>++<ol class="ovp-results" data-pics-results aria-live="polite"></ol>++</form>`;++ }++++ // ── Home ────────────────────────────────────────────────++ r.get('/', async (req, res) => {++ const mine = signedIn(req) ? (await store.imagesOf(s, whoOf(req).requester, { limit: 12 })).rows : [];++ const canUpload = signedIn(req) || cfg.anonymous;++ return page(req, res, {++ index: true,++ description: 'Upload a picture and get a link to share or embed anywhere. Albums, no ads, open source, with location and camera details removed from every picture.',++ scripts: canUpload ? ['js/pics.js'] : [],++ body: html`<header class="ovp-hero"><p class="ovp-eyebrow">${SITE_NAME}</p>++<h1>Share pictures. <span class="ovp-accent">Fast, open, yours.</span></h1>++<p class="ovp-lede">Drop a picture, get a link to share or embed anywhere: on a forum, in a chat, on your site. No ads, no feed, and nothing about you hidden in the file.</p></header>++${canUpload ? uploadForm(req) : html`<section class="ovp-drop ovp-drop--signin"><div class="ovp-drop-inner"><p class="ovp-drop-title">Sign in to upload</p><p class="ovp-drop-hint">Your OpenVibe account keeps your pictures and albums together, so you can delete them whenever you like.</p><p>${signInLink('/')}</p></div></section>`}++${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><div class="ovp-section-head"><h2 id="h-latest">Your latest pictures</h2><a href="/mine">All of them</a></div>${grid(mine, 'Nothing yet: your uploads show up here.')}</section>` : ''}++<section class="ovp-section ovp-features" aria-label="What you get">++<div class="ovp-feature"><h3>Links that work everywhere</h3><p>A page to share, a direct link for <code><img></code>, and Markdown, BBCode and HTML ready to copy.</p></div>++<div class="ovp-feature"><h3>Unlisted by default</h3><p>Only people with the link see a picture. Nothing is listed, ranked or indexed.</p></div>++<div class="ovp-feature"><h3>Clean files</h3><p>Every picture is re-encoded, and its GPS position, camera serial and other hidden details are removed before anyone sees it.</p></div>++<div class="ovp-feature"><h3>Albums</h3><p>Put pictures together and share the album with one link.</p></div>++</section>`,++ });++ });++++ // ── Upload (the no-JavaScript form; the script uses the API) ──++ const spoolDir = () => config.tmpDir || path.join(os.tmpdir(), 'mediahub-uploads');++ const uploadFailed = (req, res, detail, status = 422) => page(req, res, {++ title: 'Upload failed', scripts: ['js/pics.js'],++ body: html`<h1>That upload did not work</h1>${notice(detail, 'warn')}${uploadForm(req, { compact: true })}`,++ }, status);++++ r.post('/upload', budgeted('pics.image.upload'), async (req, res) => {++ if (!signedIn(req) && !cfg.anonymous) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in to upload</h1><p>${signInLink('/')}</p>` }, 401);++ if (!sameSite(req, res, '/')) return undefined;++ const type = String(req.get('content-type') || '');++ const boundary = /^multipart\/form-data/i.test(type) ? boundaryOf(type) : null;++ if (!boundary) return uploadFailed(req, res, 'That was not a picture upload. Choose a picture and send the form again.');++ const max = (signedIn(req) ? cfg.maxImageBytes : cfg.anonymousMaxImageBytes) + 64 * 1024;++ fs.mkdirSync(spoolDir(), { recursive: true });++ const tmp = path.join(spoolDir(), `pic-${crypto.randomBytes(8).toString('hex')}`);++ const got = await readFormData(req, { boundary, filePath: tmp, maxBytes: max });++ try {++ if (got.error === 'too_large') return uploadFailed(req, res, `A picture can be up to ${formatBytes(max - 64 * 1024)}.`, 413);++ if (got.error || !got.file || !got.file.size) return uploadFailed(req, res, 'No picture arrived. Choose one and send the form again.');++ const out = await pics.upload(whoOf(req), { buffer: fs.readFileSync(got.file.path), name: got.file.filename }, { title: got.fields.title || '' });++ if (!out.ok) return uploadFailed(req, res, out.detail, out.status);++ if (out.deleteKey) return picturePage(req, res, out.image, { deleteKey: out.deleteKey, fresh: true });++ return res.redirect(303, `/i/${out.image.slug}?new=1`);++ } finally {++ fs.rm(tmp, { force: true }, () => {});++ }++ });++++ // ── A picture ───────────────────────────────────────────++ function copyRow(label, value, id) {++ return html`<div class="ovp-copy"><label for="${id}">${label}</label><div class="ovp-copy-row"><input id="${id}" type="text" readonly value="${value}" spellcheck="false"><button class="ovp-btn" type="button" data-copy="${id}">Copy</button></div></div>`;++ }++++ async function picturePage(req, res, row, { deleteKey = null, fresh = false, message = null } = {}) {++ const who = whoOf(req);++ const owner = pics.isOwner(row, who);++ const L = pics.links(row);++ const albums = owner ? await store.albumsOf(s, who.requester) : [];++ const holding = owner ? await store.albumsHolding(s, row.id) : [];++ const title = row.title || 'Untitled picture';++ const hidden = row.state !== 'active';++ return page(req, res, {++ title, noindex: true,++ description: row.description || `${title}: a picture on ${SITE_NAME}.`,++ image: hidden ? undefined : L.direct, ogType: 'article',++ scripts: ['js/pics.js'],++ private: deleteKey ? true : undefined,++ body: html`${message ? notice(message, 'ok') : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}++${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}++${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff look at reports about it. Only you and staff can see it.' : 'This picture was taken down.', 'warn') : ''}++<article class="ovp-picture">++<h1 class="ovp-title">${title}</h1>++<figure class="ovp-figure"><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${title}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>++<p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>++${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}++</article>++<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>++${copyRow('Link to this page', L.page, 'c-page')}${copyRow('Direct link (for <img> and forums)', L.direct, 'c-direct')}${copyRow('Markdown', L.markdown, 'c-md')}${copyRow('BBCode (forums)', L.bbcode, 'c-bb')}${copyRow('HTML', L.html, 'c-html')}++</section>++${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-yours"><h2 id="h-yours">Yours</h2>++<form class="ovp-form" method="post" action="/i/${row.slug}/edit">++<label for="e-title">Title</label><input id="e-title" name="title" type="text" maxlength="120" value="${row.title}">++<label for="e-desc">Description</label><textarea id="e-desc" name="description" rows="3" maxlength="2000">${row.description}</textarea>++<p><button class="ovp-btn ovp-btn--primary" type="submit">Save</button></p></form>++<form class="ovp-form ovp-inline" method="post" action="/i/${row.slug}/album">++<label for="e-album">Add to an album</label>++<select id="e-album" name="album">${albums.map((a) => html`<option value="${a.slug}">${a.title}</option>`)}<option value="">A new album…</option></select>++<input name="new_title" type="text" maxlength="120" placeholder="New album name" aria-label="New album name">++<button class="ovp-btn" type="submit">Add</button></form>++${holding.length ? html`<p class="muted">In ${holding.map((a, i) => html`${i ? ', ' : ''}<a href="/a/${a.slug}">${a.title}</a>`)}.</p>` : ''}++<form class="ovp-form ovp-inline" method="post" action="/i/${row.slug}/delete" data-confirm="Delete this picture for good?"><button class="ovp-btn ovp-btn--danger" type="submit">Delete this picture</button> <span class="muted">Its links stop working at once.</span></form>++</section>` : ''}++${!owner && !hidden ? html`<details class="ovp-report"><summary>Report this picture</summary>++<form class="ovp-form" method="post" action="/i/${row.slug}/report">++<fieldset><legend>What is wrong with it?</legend>++${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name="reason" value="${r}" required> ${pics.REASON_TEXT[r]}</label>`)}++</fieldset>++<label for="r-note">Anything staff should know (optional)</label><textarea id="r-note" name="note" rows="2" maxlength="500"></textarea>++<p><button class="ovp-btn" type="submit">Send the report</button> <span class="muted">Copyright owners can also send a notice through <a href="/dmca">the DMCA page</a>.</span></p>++</form></details>` : ''}`,++ });++ }++++ r.get('/i/:slug', async (req, res) => {++ const { row, status } = await picture(req, res);++ if (!row || status !== 200) return notFound(req, res, status);++ return picturePage(req, res, row);++ });++++ const ownPicture = async (req, res) => {++ const { row } = await picture(req, res);++ if (!row || !pics.isOwner(row, whoOf(req))) { notFound(req, res); return null; }++ return row;++ };++++ r.post('/i/:slug/edit', mustSignIn('Sign in to change your picture.'), budgeted('pics.image.write'), async (req, res) => {++ if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;++ const row = await ownPicture(req, res);++ if (!row) return undefined;++ await pics.edit(row, { title: req.body.title, description: req.body.description });++ return res.redirect(303, `/i/${row.slug}`);++ });++++ r.post('/i/:slug/album', mustSignIn('Sign in to use albums.'), budgeted('pics.image.write'), async (req, res) => {++ if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;++ const row = await ownPicture(req, res);++ if (!row) return undefined;++ const owner = whoOf(req).requester;++ let album = req.body.album ? await store.albumBySlug(s, String(req.body.album)) : null;++ if (album && album.owner !== owner) album = null;++ if (!album) {++ const made = await pics.createAlbum(owner, { title: req.body.new_title });++ if (!made.ok) return picturePage(req, res, row, { message: made.detail });++ album = made.album;++ }++ const added = await pics.addToAlbum(album, row);++ if (!added.ok) return picturePage(req, res, row, { message: added.detail });++ return res.redirect(303, `/a/${album.slug}`);++ });++++ r.post('/i/:slug/delete', budgeted('pics.image.write'), async (req, res) => {++ if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;++ const { row } = await picture(req, res);++ const byKey = row && pics.deleteKeyMatches(row, String(req.body.key || ''));++ if (!row || !(pics.isOwner(row, whoOf(req)) || byKey)) return notFound(req, res);++ await pics.remove(row);++ return page(req, res, { title: 'Deleted', body: html`<h1>Deleted</h1><p>The picture and its links are gone.</p><p><a class="ovp-btn ovp-btn--primary" href="/">Upload another</a>${signedIn(req) ? html` <a class="ovp-btn" href="/mine">Your pictures</a>` : ''}</p>` });++ });++++ // The delete link a signed-out uploader keeps: a page with one button (a GET never deletes).++ r.get('/i/:slug/delete', async (req, res) => {++ const { row } = await picture(req, res);++ const key = String(req.query.key || '');++ if (!row || !pics.deleteKeyMatches(row, key)) return notFound(req, res);++ return page(req, res, {++ title: 'Delete this picture', noindex: true, private: true,++ body: html`<h1>Delete this picture?</h1><p><img class="ovp-confirm-thumb" src="/i/${row.slug}/thumb.webp" alt=""></p>++<form method="post" action="/i/${row.slug}/delete"><input type="hidden" name="key" value="${key}"><button class="ovp-btn ovp-btn--danger" type="submit">Delete it for good</button> <a href="/i/${row.slug}">Keep it</a></form>`,++ });++ });++++ r.post('/i/:slug/report', budgeted('pics.report.create'), async (req, res) => {++ if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;++ const { row } = await picture(req, res);++ if (!row || row.state !== 'active') return notFound(req, res);++ const who = whoOf(req);++ const out = await pics.report(row, { reporter: who.requester || pics.addressId(who.ip), reason: String(req.body.reason || ''), note: req.body.note });++ if (!out.ok) return picturePage(req, res, row, { message: out.detail });++ return page(req, res, {++ title: 'Report sent', private: true,++ body: html`<h1>Thank you</h1><p>${out.added ? 'Your report was sent to OpenVibe staff.' : 'You had already reported this picture; staff have your report.'}${out.hidden ? ' The picture is hidden until staff look at it.' : ''}</p><p><a href="/">Back to ${SITE_NAME}</a></p>`,++ });++ });++++ // ── Your pictures and albums ────────────────────────────++ r.get('/mine', mustSignIn('Your pictures belong to your OpenVibe account.'), async (req, res) => {++ const before = /^pic_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.query.before || '')) ? String(req.query.before) : null;++ const owner = whoOf(req).requester;++ const out = await store.imagesOf(s, owner, { limit: 48, before });++ const use = await store.usageOf(s, owner);++ return page(req, res, {++ title: 'Your pictures', scripts: ['js/pics.js'],++ body: html`<div class="ovp-section-head"><h1>Your pictures</h1><span class="muted">${use.images} picture${use.images === 1 ? '' : 's'} · ${formatBytes(use.bytes)} of ${formatBytes(cfg.quotaBytes)}</span></div>++${uploadForm(req, { compact: true })}++${grid(out.rows, 'Nothing yet. Upload a picture above.')}++${out.next ? html`<p><a class="ovp-btn" href="/mine?before=${out.next}">Older pictures</a></p>` : ''}`,++ });++ });++++ r.get('/albums', mustSignIn('Albums belong to your OpenVibe account.'), async (req, res) => {++ const albums = await store.albumsOf(s, whoOf(req).requester);++ return page(req, res, {++ title: 'Your albums',++ body: html`<h1>Your albums</h1>++<form class="ovp-form ovp-inline" method="post" action="/albums"><label for="a-title">New album</label><input id="a-title" name="title" type="text" maxlength="120" required placeholder="Trip to the coast"><button class="ovp-btn ovp-btn--primary" type="submit">Create</button></form>++${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class="ovp-album" href="/a/${a.slug}">${a.cover_slug ? html`<img src="/i/${a.cover_slug}/thumb.webp" alt="" loading="lazy">` : html`<span class="ovp-album-empty" aria-hidden="true"></span>`}<span class="ovp-album-title">${a.title}</span><span class="muted">${a.image_count} picture${Number(a.image_count) === 1 ? '' : 's'}</span></a>`)}</div>`++ : html`<p class="muted">No albums yet. Create one here, or add a picture to a new album from its page.</p>`}`,++ });++ });++++ r.post('/albums', mustSignIn('Sign in to use albums.'), budgeted('pics.album.create'), async (req, res) => {++ if (!sameSite(req, res, '/albums')) return undefined;++ const made = await pics.createAlbum(whoOf(req).requester, { title: req.body.title });++ if (!made.ok) return page(req, res, { title: 'Album', body: html`<h1>Your albums</h1>${notice(made.detail, 'warn')}<p><a href="/albums">Back</a></p>` }, made.status);++ return res.redirect(303, `/a/${made.album.slug}`);++ });++++ async function ownAlbum(req, res) {++ const slug = String(req.params.slug || '');++ const album = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;++ if (!album || album.owner !== whoOf(req).requester) { notFound(req, res); return null; }++ return album;++ }++++ r.get('/a/:slug', async (req, res) => {++ const slug = String(req.params.slug || '');++ const album = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;++ if (!album) return notFound(req, res);++ const owner = album.owner === whoOf(req).requester;++ const rows = await store.albumImages(s, album.id, { visibleOnly: !owner });++ return page(req, res, {++ title: album.title, noindex: true, scripts: ['js/pics.js'],++ image: rows[0] ? `${base}/i/${rows[0].slug}.${rows[0].ext}` : undefined,++ body: html`<div class="ovp-section-head"><h1>${album.title}</h1><span class="muted">${rows.length} picture${rows.length === 1 ? '' : 's'}</span></div>++${album.description ? html`<p class="ovp-desc">${album.description}</p>` : ''}++${copyRow('Link to this album', `${base}/a/${album.slug}`, 'c-album')}++${grid(rows, owner ? 'Empty. Add pictures from their pages.' : 'Nothing to show here.')}++${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-manage"><h2 id="h-manage">Manage</h2>++<form class="ovp-form" method="post" action="/a/${album.slug}/edit"><label for="al-title">Name</label><input id="al-title" name="title" type="text" maxlength="120" value="${album.title}" required>++<label for="al-desc">Description</label><textarea id="al-desc" name="description" rows="2" maxlength="2000">${album.description}</textarea><p><button class="ovp-btn ovp-btn--primary" type="submit">Save</button></p></form>++${rows.length ? html`<form class="ovp-form ovp-inline" method="post" action="/a/${album.slug}/remove"><label for="al-rm">Take out of the album</label><select id="al-rm" name="image">${rows.map((x) => html`<option value="${x.slug}">${x.title || x.slug}</option>`)}</select><button class="ovp-btn" type="submit">Take out</button></form>` : ''}++<form class="ovp-form ovp-inline" method="post" action="/a/${album.slug}/delete" data-confirm="Delete this album? The pictures stay."><button class="ovp-btn ovp-btn--danger" type="submit">Delete the album</button> <span class="muted">The pictures in it stay.</span></form>++</section>` : ''}`,++ });++ });++++ r.post('/a/:slug/edit', mustSignIn('Sign in to use albums.'), budgeted('pics.image.write'), async (req, res) => {++ if (!sameSite(req, res, `/a/${req.params.slug}`)) return undefined;++ const album = await ownAlbum(req, res);++ if (!album) return undefined;++ const title = String(req.body.title || '').replace(/\s+/g, ' ').trim().slice(0, pics.LIMITS.albumTitle);++ await store.updateAlbum(s, album.id, { title: title || null, description: req.body.description == null ? null : String(req.body.description).trim().slice(0, pics.LIMITS.description) });++ return res.redirect(303, `/a/${album.slug}`);++ });++++ r.post('/a/:slug/remove', mustSignIn('Sign in to use albums.'), budgeted('pics.image.write'), async (req, res) => {++ if (!sameSite(req, res, `/a/${req.params.slug}`)) return undefined;++ const album = await ownAlbum(req, res);++ if (!album) return undefined;++ const image = pics.isSlug(req.body.image) ? await store.imageBySlug(s, String(req.body.image)) : null;++ if (image) await store.removeFromAlbum(s, album.id, image.id);++ return res.redirect(303, `/a/${album.slug}`);++ });++++ r.post('/a/:slug/delete', mustSignIn('Sign in to use albums.'), budgeted('pics.image.write'), async (req, res) => {++ if (!sameSite(req, res, `/a/${req.params.slug}`)) return undefined;++ const album = await ownAlbum(req, res);++ if (!album) return undefined;++ await store.deleteAlbum(s, album.id);++ return res.redirect(303, '/albums');++ });++++ // ── Staff ───────────────────────────────────────────────++ const staffOnly = (req, res, next) => (signedIn(req) && pics.isStaff(req.viewer) ? next() : notFound(req, res));++ r.get('/staff', staffOnly, async (req, res) => {++ const state = ['hidden', 'reported', 'removed'].includes(String(req.query.state)) ? String(req.query.state) : 'hidden';++ const rows = await store.staffQueue(s, { state });++ const counts = await store.staffCounts(s);++ const items = [];++ for (const row of rows) items.push({ row, reports: await store.reportsFor(s, row.id) });++ const tab = (k, label) => (k === state ? html`<span class="ovp-tab on" aria-current="page">${label} (${counts[k]})</span>` : html`<a class="ovp-tab" href="/staff?state=${k}">${label} (${counts[k]})</a>`);++ return page(req, res, {++ title: 'Pics reports', private: true,++ body: html`<h1>Reports</h1><nav class="ovp-tabs">${tab('hidden', 'Hidden, waiting')}${tab('reported', 'Reported, still up')}${tab('removed', 'Taken down')}</nav>++${items.length ? items.map(({ row, reports }) => html`<article class="ovp-staff">++${row.state !== 'removed' ? html`<a href="/i/${row.slug}"><img src="/i/${row.slug}/thumb.webp" alt="" loading="lazy"></a>` : html`<span class="ovp-album-empty" aria-hidden="true"></span>`}++<div><p><b>${row.title || row.slug}</b> · ${row.state} · ${row.report_count} report${Number(row.report_count) === 1 ? '' : 's'} · ${row.owner ? 'by an account' : 'signed out'} · ${time(row.created_at)}</p>++<ul>${reports.map((x) => html`<li><b>${pics.REASON_TEXT[x.reason] || x.reason}</b>${x.note ? html`: ${x.note}` : ''} <span class="muted">${time(x.created_at)}</span></li>`)}</ul>++${row.state !== 'removed' ? html`<form class="ovp-inline" method="post" action="/staff/i/${row.slug}">++${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="restore">Looks fine: show it</button>` : html`<button class="ovp-btn" name="action" value="hide">Hide</button>`}++<input name="reason" type="text" maxlength="60" placeholder="Why (kept with the blocklist)" aria-label="Why it is taken down">++<button class="ovp-btn ovp-btn--danger" name="action" value="takedown">Take down and block</button></form>` : ''}</div></article>`) : html`<p class="muted">Nothing here.</p>`}++<p class="small muted">Taking a picture down deletes its bytes and puts its hashes on the blocklist: the same picture, or a resized or re-compressed copy, cannot be uploaded again. Child sexual abuse material must also be reported to NCMEC's CyberTipline.</p>`,++ });++ });++++ r.post('/staff/i/:slug', staffOnly, async (req, res) => {++ if (!sameSite(req, res, '/staff')) return undefined;++ const slug = String(req.params.slug || '');++ const row = pics.isSlug(slug) ? await store.imageBySlug(s, slug) : null;++ if (!row) return notFound(req, res);++ await pics.moderate(row, String(req.body.action || ''), { by: whoOf(req).requester, reason: req.body.reason });++ return res.redirect(303, `/staff?state=${row.state === 'active' ? 'reported' : 'hidden'}`);++ });++++ // ── Discovery, legal, update log ────────────────────────++ r.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))++ .send(`# openvibe.pics: pictures are unlisted; only the home page is for search engines.\n${seo.robotsTxt({ disallow: ['/i/', '/a/', '/mine', '/albums', '/staff', '/api/', '/auth/', '/upload'] })}`));++ r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({++ name: SITE_NAME,++ summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, no ads.',++ details: 'Pictures are unlisted (reachable only by their link). Each one is re-encoded with its metadata removed. API: POST /api/v1/images with the picture as the body (an OpenVibe sign-in token as a Bearer).',++ sections: [{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }, { title: 'Terms', url: `${base}/terms` }, { title: 'DMCA', url: `${base}/dmca` }] }],++ })));++ r.get('/updates', (req, res) => page(req, res, {++ index: true, title: 'What shipped on OpenVibe.Pics', description: 'Every change deployed to OpenVibe.Pics, newest first.',++ body: raw(frame.updatesBody({ service: 'media-hub', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`),++ }));++ const legal = require('openvibe-shared/legal');++ r.get(legal.PATHS, legal.handler({ id: 'media-hub', service: 'media-hub', host, name: SITE_NAME, profile: 'ugc' }));++++ r.use((req, res) => notFound(req, res));++ // eslint-disable-next-line no-unused-vars++ r.use((err, req, res, _next) => {++ log.error('[OpenVibe.Pics]', err && err.message ? err.message.slice(0, 300) : err);++ if (res.headersSent) return undefined;++ if (err && err.type === 'entity.too.large') return res.status(413).type('text/plain').send('That request was too large.');++ return page(req, res, { title: 'Something went wrong', cache: cache.htmlHeaders({ private: true }), body: html`<h1>Something went wrong on our side</h1><p class="muted">Try again in a moment.</p>` }, 500);++ });++ return r;++}++++module.exports = { createPicsRoutes };nnew file mode 100644@@@ -0,0 +1,246 @@++'use strict';++++/**++ * OpenVibe.Pics: the rules. The pages (server/pics/pages.js) and the API (server/pics/api.js) both come through here,++ * so a form and an API call can never mean different things.++ *++ * upload a picture from a person (or signed out, when MEDIAHUB_PICS_ANONYMOUS=1): size, daily count and++ * quota checks, then images.prepare() (decode, re-encode without metadata, thumbnail, hashes), then++ * the blocklist, then two private Media objects and the row. A signed-out upload gets a delete key,++ * shown once and kept only as its sha256.++ * report one per reporter per picture. `child_safety` hides the picture at once; any other reason hides it++ * at MEDIAHUB_PICS_REPORTS_TO_HIDE distinct reports. Hidden is 404 for everyone but its uploader and++ * staff, until staff decide. The bytes are private Media objects that only this service reads and serves++ * (server/pics/pages.js /i/<slug>.<ext>), so hiding a picture stops it everywhere at once, apart from++ * what a cache already holds for its last few minutes.++ * takedown staff: the picture is removed, its Media objects deleted, and its hashes go on the blocklist so the++ * same picture (or a near-duplicate) cannot be uploaded again.++ * remove the uploader (or a signed-out uploader with the delete key) deletes it, bytes and all.++ * sweep signed-out pictures nobody has viewed for MEDIAHUB_PICS_ANONYMOUS_IDLE_DAYS are deleted.++ *++ * Who may see a picture: active ones, anyone with the link; hidden ones, the uploader and staff; removed ones, staff.++ */++const crypto = require('crypto');++const images = require('./images');++const store = require('./store');++const { isStaff } = require('../drive/staff');++++const SLUG_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ';++const SLUG_RE = /^[2-9a-km-zA-HJ-NP-Z]{8}$/;++const REASONS = ['child_safety', 'illegal', 'copyright', 'sexual', 'violence', 'harassment', 'private_info', 'spam', 'other'];++const REASON_TEXT = {++ child_safety: 'Child sexual abuse or exploitation', illegal: 'Other illegal content', copyright: 'Copyright infringement (see the DMCA page)',++ sexual: 'Sexual content shared without consent', violence: 'Graphic violence or threats', harassment: 'Harassment or hate',++ private_info: 'Someone\'s private information', spam: 'Spam or a scam', other: 'Something else',++};++const LIMITS = { title: 120, description: 2000, albumTitle: 120, note: 500 };++// A difference hash this close (of 64 bits) to a blocked one is the same picture, resized, re-compressed or retouched:++// measured 2026-10-15, such copies of real photos land 0-10 bits away and different photos 23-42.++const DHASH_DISTANCE = 10;++++const slug = () => Array.from(crypto.randomBytes(8), (b) => SLUG_ALPHABET[b % SLUG_ALPHABET.length]).join('');++const cleanLine = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);++const cleanText = (v, max) => String(v == null ? '' : v).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, max);++/** A hash with too few or too many set bits is a near-flat picture: its dHash is noise, not an identity. */++const distinctive = (h) => { const n = images.distance(h, '0000000000000000'); return n >= 8 && n <= 56; };++++function createPics({ s, media, config, log = console }) {++ const cfg = config.pics;++ const addressKey = crypto.createHash('sha256').update(`pics-address:${config.oauth.clientSecret || 'dev'}`).digest();++++ /** A signed-out caller as a stable, keyed hash of their address: counted and deduplicated, never stored as is. */++ const addressId = (ip) => `ip:${crypto.createHmac('sha256', addressKey).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;++ const day = () => s.iso().slice(0, 10);++++ /** May `who` ({ requester, staff }, see whoOf in pages.js and api.js) see this picture? */++ function canSee(row, who) {++ if (!row) return false;++ if (row.state === 'active') return true;++ if (who && who.staff) return true;++ return row.state === 'hidden' && Boolean(who && who.requester && row.owner === who.requester);++ }++ const isOwner = (row, viewer) => Boolean(row && row.owner && viewer && viewer.requester && row.owner === viewer.requester);++++ async function putObject(buf, { mime, name, owner, anonymous }) {++ const init = await media.init({ size: buf.length, contentType: mime, name, ownerSubject: owner, visibility: 'private', ownerless: anonymous });++ if (!init.ok) return init;++ const id = init.data && (init.data.id || (init.data.object && init.data.object.id));++ const put = await media.putContent(id, buf);++ if (!put.ok) { await media.remove(id).catch(() => {}); return put; }++ const done = await media.complete(id);++ if (!done.ok) { await media.remove(id).catch(() => {}); return done; }++ return { ok: true, id };++ }++++ /** Is this picture on the takedown blocklist (exactly, or as a near-duplicate)? */++ async function blocked(prepared) {++ const exact = await store.blockedSha(s, [prepared.sha256, prepared.sourceSha256]);++ if (exact) return exact;++ if (!distinctive(prepared.dhash)) return null;++ const near = (await store.blockedDhashes(s)).find((b) => images.distance(b.value, prepared.dhash) <= DHASH_DISTANCE);++ return near || null;++ }++++ /**++ * Upload one picture. who: { requester } (a person) or { anonymous: true, ip }. file: { buffer, name }.++ * → { ok, image, deleteKey? } or { ok: false, status, code, detail }.++ */++ async function upload(who, file, { title = '', description = '' } = {}) {++ const anonymous = !who || !who.requester;++ if (anonymous && !cfg.anonymous) return { ok: false, status: 401, code: 'pics.sign_in_required', detail: 'Sign in with OpenVibe to upload pictures.' };++ const buf = file && file.buffer;++ const max = anonymous ? cfg.anonymousMaxImageBytes : cfg.maxImageBytes;++ if (!buf || !buf.length) return { ok: false, status: 422, code: 'pics.empty', detail: 'Choose a picture to upload.' };++ if (buf.length > max) return { ok: false, status: 413, code: 'pics.too_large', detail: `A picture can be up to ${Math.round(max / 1024 / 1024)} MB.` };++ const uploader = anonymous ? addressId(who && who.ip) : who.requester;++ const perDay = anonymous ? cfg.anonymousPerDay : cfg.imagesPerDay;++ if (await store.uploadsToday(s, uploader, day()) >= perDay) return { ok: false, status: 429, code: 'pics.daily_limit', detail: `That is ${perDay} uploads today, the most a day. Try again tomorrow.` };++ if (!anonymous) {++ const use = await store.usageOf(s, who.requester);++ if (use.bytes + buf.length > cfg.quotaBytes) return { ok: false, status: 409, code: 'pics.quota', detail: `Your pictures would pass ${Math.round(cfg.quotaBytes / 1024 ** 3)} GB. Delete some first.` };++ }++++ const prepared = await images.prepare(buf, { maxPixels: cfg.maxPixels });++ if (!prepared.ok) return { ok: false, status: 422, code: prepared.code, detail: prepared.detail };++ const hit = await blocked(prepared);++ if (hit) {++ (log.warn || log.log)(`[OpenVibe.Pics] refused an upload matching a takedown (${hit.reason})`);++ return { ok: false, status: 422, code: 'pics.blocked', detail: 'This picture was taken down from OpenVibe.Pics and cannot be uploaded again.' };++ }++ await store.countUpload(s, uploader, day());++++ const id = s.newId('pic');++ const base = cleanLine(title, LIMITS.title) || cleanLine(String(file.name || '').replace(/\.[A-Za-z0-9]{1,5}$/, ''), LIMITS.title);++ const main = await putObject(prepared.image.buffer, { mime: prepared.image.mime, name: `${id}.${prepared.image.ext}`, owner: who && who.requester, anonymous });++ if (!main.ok) return { ok: false, status: 503, code: 'pics.storage', detail: 'The picture could not be stored just now. Try again in a minute.' };++ const thumb = await putObject(prepared.thumb.buffer, { mime: 'image/webp', name: `${id}-thumb.webp`, owner: who && who.requester, anonymous });++ const deleteKey = anonymous ? crypto.randomBytes(18).toString('base64url') : null;++ const row = await store.insertImage(s, {++ id, slug: slug(), owner: anonymous ? null : who.requester,++ delete_key_hash: deleteKey ? images.sha256(Buffer.from(deleteKey)) : null,++ title: base, description: cleanText(description, LIMITS.description),++ object_id: main.id, thumb_object_id: thumb.ok ? thumb.id : null,++ mime_type: prepared.image.mime, ext: prepared.image.ext, width: prepared.image.width, height: prepared.image.height,++ bytes: prepared.image.buffer.length, animated: prepared.image.animated,++ sha256: prepared.sha256, source_sha256: prepared.sourceSha256, dhash: prepared.dhash, created_at: s.iso(),++ });++ return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}) };++ }++++ async function edit(row, { title, description }) {++ return store.updateImage(s, row.id, {++ ...(title !== undefined ? { title: cleanLine(title, LIMITS.title) } : {}),++ ...(description !== undefined ? { description: cleanText(description, LIMITS.description) } : {}),++ });++ }++++ const hide = (row) => store.updateImage(s, row.id, { state: 'hidden' });++++ async function removeObjects(row) {++ for (const oid of [row.object_id, row.thumb_object_id].filter(Boolean)) {++ const r = await media.remove(oid);++ if (!r.ok && r.status !== 404) (log.warn || log.log)(`[OpenVibe.Pics] Media kept ${oid}: ${r.code || r.status}`);++ }++ }++++ /** The uploader deletes it: the bytes in Media and the row (its reports and album places go with it). */++ async function remove(row) {++ await removeObjects(row);++ await store.deleteImage(s, row.id);++ }++++ /** A signed-out upload's delete key matches? (Compared as hashes, in constant time.) */++ function deleteKeyMatches(row, key) {++ if (!row || !row.delete_key_hash || typeof key !== 'string' || !key) return false;++ const a = Buffer.from(images.sha256(Buffer.from(key)));++ const b = Buffer.from(row.delete_key_hash);++ return a.length === b.length && crypto.timingSafeEqual(a, b);++ }++++ async function report(row, { reporter, reason, note }) {++ const r = REASONS.includes(reason) ? reason : null;++ if (!r) return { ok: false, status: 422, code: 'pics.report_reason', detail: `reason: one of ${REASONS.join(', ')}` };++ const out = await store.addReport(s, row.id, { id: s.newId('prp'), reporter, reason: r, note: cleanText(note, LIMITS.note) });++ let hidden = false;++ if (row.state === 'active' && (r === 'child_safety' || out.count >= cfg.reportsToHide)) {++ await hide(row);++ hidden = true;++ }++ if (r === 'child_safety' && out.added) (log.warn || log.log)(`[OpenVibe.Pics] child-safety report on ${row.id}: hidden until staff review`);++ return { ok: true, added: out.added, count: out.count, hidden };++ }++++ /** Staff: restore (active), hide, or take down (removed, bytes deleted, hashes blocklisted). */++ async function moderate(row, action, { by, reason = 'staff' } = {}) {++ if (action === 'restore') {++ if (row.state === 'removed') return { ok: false, row, detail: 'A picture that was taken down stays down; its bytes are gone.' };++ return { ok: true, row: await store.updateImage(s, row.id, { state: 'active' }) };++ }++ if (action === 'hide') return { ok: true, row: row.state === 'active' ? await hide(row) : row };++ if (action !== 'takedown') return { ok: false, row, detail: 'action: restore, hide or takedown' };++ for (const [kind, value] of [['sha256', row.sha256], ['sha256', row.source_sha256], ['dhash', row.dhash]]) {++ if (kind === 'dhash' && !distinctive(value)) continue;++ await store.block(s, { kind, value, reason: cleanLine(reason, 60) || 'staff', imageId: row.id, by });++ }++ await removeObjects(row);++ return { ok: true, row: await store.updateImage(s, row.id, { state: 'removed' }) };++ }++++ /** Count a view for the retention clock, at most once an hour per picture. */++ async function viewed(row) {++ const last = Date.parse(row.last_viewed_at || 0);++ if (Number.isFinite(last) && s.now() - last < 3_600_000) return;++ await store.updateImage(s, row.id, { last_viewed_at: s.iso() });++ }++++ async function sweep() {++ const before = new Date(s.now() - cfg.anonymousIdleDays * 86_400_000).toISOString();++ const rows = await store.idleAnonymous(s, before);++ for (const row of rows) await remove(row);++ return rows.length;++ }++++ // ── Albums (account-only) ───────────────────────────────++ async function createAlbum(owner, { title, description = '' }) {++ const t = cleanLine(title, LIMITS.albumTitle);++ if (!t) return { ok: false, status: 422, code: 'pics.album_title', detail: 'Give the album a name.' };++ if (await store.albumCount(s, owner) >= cfg.albumsMax) return { ok: false, status: 409, code: 'pics.album_limit', detail: `You have ${cfg.albumsMax} albums, the most you can.` };++ const row = await store.insertAlbum(s, { id: s.newId('alb'), slug: slug(), owner, title: t, description: cleanText(description, LIMITS.description), created_at: s.iso() });++ return { ok: true, album: row };++ }++++ async function addToAlbum(album, image) {++ if (await store.albumImageCount(s, album.id) >= cfg.albumImagesMax) return { ok: false, status: 409, code: 'pics.album_full', detail: `An album holds ${cfg.albumImagesMax} pictures.` };++ await store.addToAlbum(s, album.id, image.id);++ return { ok: true };++ }++++ /** The public links of a picture: its page, the direct file, the thumbnail and the embed snippets. */++ function links(row) {++ const base = cfg.url;++ const page = `${base}/i/${row.slug}`;++ const direct = `${base}/i/${row.slug}.${row.ext}`;++ const alt = row.title || 'Picture on OpenVibe.Pics';++ return {++ page, direct, thumb: `${base}/i/${row.slug}/thumb.webp`,++ markdown: `![${alt.replace(/[[\]]/g, '')}](${direct})`,++ html: `<a href="${page}"><img src="${direct}" alt="${alt.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))}"></a>`,++ bbcode: `[url=${page}][img]${direct}[/img][/url]`,++ };++ }++++ const toWire = (row, { owner = false } = {}) => ({++ id: row.id, slug: row.slug, title: row.title, description: row.description, mime_type: row.mime_type, ext: row.ext,++ width: Number(row.width), height: Number(row.height), bytes: Number(row.bytes), animated: Boolean(row.animated),++ state: row.state, created_at: row.created_at, links: links(row),++ ...(owner ? { report_count: Number(row.report_count) } : {}),++ });++++ return {++ cfg, REASONS, REASON_TEXT, LIMITS, SLUG_RE, isSlug: (v) => SLUG_RE.test(String(v || '')),++ addressId, canSee, isOwner, upload, edit, remove, deleteKeyMatches, report, moderate, viewed, sweep,++ createAlbum, addToAlbum, links, toWire, isStaff,++ };++}++++module.exports = { createPics, REASONS, SLUG_RE, DHASH_DISTANCE };nnew file mode 100644@@@ -0,0 +1,126 @@++'use strict';++++/**++ * OpenVibe.Pics' rows (migrations/0006_pics.sql). Every function takes the store (server/db.js createStore) first.++ * Pictures and albums are read by slug from the pages and by id inside; a picture that is not `active` is shown to++ * nobody but its uploader and staff (server/pics/service.js decides who).++ */++++const IMAGE_COLS = ['title', 'description', 'state', 'report_count', 'last_viewed_at', 'thumb_object_id'];++++const imageBySlug = (s, slug) => s.db.maybe('SELECT * FROM pics_images WHERE slug = $1', [slug]);++const imageById = (s, id) => s.db.maybe('SELECT * FROM pics_images WHERE id = $1', [id]);++++async function insertImage(s, row) {++ await s.db.query(++ `INSERT INTO pics_images (id, slug, owner, delete_key_hash, title, description, object_id, thumb_object_id, mime_type, ext,++ width, height, bytes, animated, sha256, source_sha256, dhash, state, report_count, created_at, updated_at, last_viewed_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'active', 0, $18, $18, $18)`,++ [row.id, row.slug, row.owner, row.delete_key_hash, row.title, row.description, row.object_id, row.thumb_object_id, row.mime_type,++ row.ext, row.width, row.height, row.bytes, row.animated, row.sha256, row.source_sha256, row.dhash, row.created_at]);++ return imageById(s, row.id);++}++++async function updateImage(s, id, patch) {++ const cols = IMAGE_COLS.filter((c) => patch[c] !== undefined);++ if (!cols.length) return imageById(s, id);++ await s.db.query(`UPDATE pics_images SET ${cols.map((c, i) => `${c} = $${i + 3}`).join(', ')}, updated_at = $2 WHERE id = $1`, [id, s.iso(), ...cols.map((c) => patch[c])]);++ return imageById(s, id);++}++++const deleteImage = (s, id) => s.db.exec('DELETE FROM pics_images WHERE id = $1', [id]);++++/** A person's pictures, newest first, a page at a time by id cursor. */++async function imagesOf(s, owner, { limit = 48, before = null } = {}) {++ const rows = await s.db.many(++ `SELECT * FROM pics_images WHERE owner = $1 AND state <> 'removed' ${before ? 'AND id < $3' : ''} ORDER BY id DESC LIMIT $2`,++ before ? [owner, limit + 1, before] : [owner, limit + 1]);++ return { rows: rows.slice(0, limit), next: rows.length > limit ? rows[limit - 1].id : null };++}++++const usageOf = async (s, owner) => {++ const r = await s.db.maybe("SELECT count(*)::int AS n, coalesce(sum(bytes), 0)::bigint AS bytes FROM pics_images WHERE owner = $1 AND state <> 'removed'", [owner]);++ return { images: Number(r ? r.n : 0), bytes: Number(r ? r.bytes : 0) };++};++++/** Count one upload for an uploader today; answers the count after it. */++async function countUpload(s, uploader, day) {++ return Number(await s.db.value(++ `INSERT INTO pics_usage (uploader, day, uploads) VALUES ($1, $2, 1)++ ON CONFLICT (uploader, day) DO UPDATE SET uploads = pics_usage.uploads + 1 RETURNING uploads`, [uploader, day]));++}++const uploadsToday = async (s, uploader, day) => Number(await s.db.value('SELECT coalesce(max(uploads), 0) FROM pics_usage WHERE uploader = $1 AND day = $2', [uploader, day]));++++// ── Blocklist ───────────────────────────────────────────────++const blockedSha = (s, values) => s.db.maybe("SELECT * FROM pics_blocklist WHERE kind = 'sha256' AND value = ANY($1::text[])", [values]);++const blockedDhashes = (s) => s.db.many("SELECT value, reason FROM pics_blocklist WHERE kind = 'dhash'");++async function block(s, { kind, value, reason, imageId, by }) {++ await s.db.query(++ `INSERT INTO pics_blocklist (kind, value, reason, image_id, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6)++ ON CONFLICT (kind, value) DO NOTHING`, [kind, value, reason, imageId, by, s.iso()]);++}++++// ── Reports ─────────────────────────────────────────────────++/** One report per reporter per picture: { added, count } — `added` false when this reporter had already reported it. */++async function addReport(s, imageId, { id, reporter, reason, note }) {++ const ins = await s.db.maybe(++ `INSERT INTO pics_reports (id, image_id, reporter, reason, note, created_at) VALUES ($1, $2, $3, $4, $5, $6)++ ON CONFLICT (image_id, reporter) DO NOTHING RETURNING id`, [id, imageId, reporter, reason, note, s.iso()]);++ const count = Number(await s.db.value('SELECT count(*)::int FROM pics_reports WHERE image_id = $1', [imageId]));++ await s.db.query('UPDATE pics_images SET report_count = $2 WHERE id = $1', [imageId, count]);++ return { added: Boolean(ins), count };++}++const reportsFor = (s, imageId) => s.db.many('SELECT * FROM pics_reports WHERE image_id = $1 ORDER BY created_at DESC', [imageId]);++++/** The staff queue: hidden pictures first (waiting on a decision), then reported active ones. */++const staffQueue = (s, { state = 'hidden', limit = 60 } = {}) => s.db.many(++ state === 'reported'++ ? "SELECT * FROM pics_images WHERE state = 'active' AND report_count > 0 ORDER BY report_count DESC, id DESC LIMIT $1"++ : 'SELECT * FROM pics_images WHERE state = $2 ORDER BY id DESC LIMIT $1',++ state === 'reported' ? [limit] : [limit, state]);++const staffCounts = async (s) => {++ const rows = await s.db.many("SELECT state, count(*)::int AS n FROM pics_images WHERE state <> 'active' OR report_count > 0 GROUP BY state");++ const reported = Number(await s.db.value("SELECT count(*)::int FROM pics_images WHERE state = 'active' AND report_count > 0"));++ return { hidden: Number((rows.find((r) => r.state === 'hidden') || {}).n || 0), removed: Number((rows.find((r) => r.state === 'removed') || {}).n || 0), reported };++};++++/** Signed-out pictures nobody has looked at since `beforeIso`: the retention sweep's work. */++const idleAnonymous = (s, beforeIso, limit = 200) => s.db.many(++ "SELECT * FROM pics_images WHERE owner IS NULL AND state <> 'removed' AND coalesce(last_viewed_at, created_at) < $1 ORDER BY id LIMIT $2", [beforeIso, limit]);++++// ── Albums ──────────────────────────────────────────────────++const albumBySlug = (s, slug) => s.db.maybe('SELECT * FROM pics_albums WHERE slug = $1', [slug]);++const albumsOf = (s, owner) => s.db.many(++ `SELECT a.*, (SELECT count(*)::int FROM pics_album_images ai WHERE ai.album_id = a.id) AS image_count,++ (SELECT i.slug FROM pics_album_images ai JOIN pics_images i ON i.id = ai.image_id WHERE ai.album_id = a.id AND i.state = 'active' ORDER BY ai.position LIMIT 1) AS cover_slug++ FROM pics_albums a WHERE a.owner = $1 ORDER BY a.id DESC`, [owner]);++const albumCount = async (s, owner) => Number(await s.db.value('SELECT count(*)::int FROM pics_albums WHERE owner = $1', [owner]));++++async function insertAlbum(s, row) {++ await s.db.query('INSERT INTO pics_albums (id, slug, owner, title, description, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $6)',++ [row.id, row.slug, row.owner, row.title, row.description, row.created_at]);++ return albumBySlug(s, row.slug);++}++async function updateAlbum(s, id, { title, description }) {++ await s.db.query('UPDATE pics_albums SET title = coalesce($2, title), description = coalesce($3, description), updated_at = $4 WHERE id = $1', [id, title, description, s.iso()]);++}++const deleteAlbum = (s, id) => s.db.exec('DELETE FROM pics_albums WHERE id = $1', [id]);++++/** The album's pictures in order; `visibleOnly` leaves out the hidden and removed ones (everyone but the owner). */++const albumImages = (s, albumId, { visibleOnly = true } = {}) => s.db.many(++ `SELECT i.* FROM pics_album_images ai JOIN pics_images i ON i.id = ai.image_id++ WHERE ai.album_id = $1 ${visibleOnly ? "AND i.state = 'active'" : "AND i.state <> 'removed'"} ORDER BY ai.position, ai.added_at`, [albumId]);++const albumImageCount = async (s, albumId) => Number(await s.db.value('SELECT count(*)::int FROM pics_album_images WHERE album_id = $1', [albumId]));++async function addToAlbum(s, albumId, imageId) {++ const pos = Number(await s.db.value('SELECT coalesce(max(position), 0) + 1 FROM pics_album_images WHERE album_id = $1', [albumId]));++ await s.db.query('INSERT INTO pics_album_images (album_id, image_id, position, added_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING', [albumId, imageId, pos, s.iso()]);++ await s.db.query('UPDATE pics_albums SET updated_at = $2 WHERE id = $1', [albumId, s.iso()]);++}++const removeFromAlbum = (s, albumId, imageId) => s.db.exec('DELETE FROM pics_album_images WHERE album_id = $1 AND image_id = $2', [albumId, imageId]);++const albumsHolding = (s, imageId) => s.db.many('SELECT a.* FROM pics_album_images ai JOIN pics_albums a ON a.id = ai.album_id WHERE ai.image_id = $1 ORDER BY a.title', [imageId]);++++module.exports = {++ imageBySlug, imageById, insertImage, updateImage, deleteImage, imagesOf, usageOf, countUpload, uploadsToday,++ blockedSha, blockedDhashes, block, addReport, reportsFor, staffQueue, staffCounts, idleAnonymous,++ albumBySlug, albumsOf, albumCount, insertAlbum, updateAlbum, deleteAlbum, albumImages, albumImageCount, addToAlbum, removeFromAlbum, albumsHolding,++};@@@ -25,6 +25,7 @@ const READ = 'media-hub.api.read';'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write','media-hub.share.create','media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',++ 'pics.album.create', 'pics.image.upload', 'pics.image.write', 'pics.report.create',]);for (const [name, own] of Object.entries(BUDGETS)) {assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);@@@ -141,8 +141,9 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {if (!body) return problem(res, 400, 'media.object.invalid', 'body is not JSON');const size = Number(body.size_bytes);if (!Number.isInteger(size) || size < 1) return problem(res, 400, 'media.object.invalid', 'size_bytes must be a positive integer');-- const subject = req.headers['x-ov-subject'];-- if (!subject || !ids.isSubjectId('user', subject)) return problem(res, 400, 'media.object.invalid', 'X-OV-Subject must be a user subject');++ // No X-OV-Subject: the tenant's own object (Media's subjectFrom answers null); a malformed one is refused.++ const subject = req.headers['x-ov-subject'] || null;++ if (subject !== null && !ids.isSubjectId('user', subject)) return problem(res, 400, 'media.object.invalid', 'X-OV-Subject must be a user subject');const wantParts = body.multipart === true;if (!wantParts && size > maxSingle) return problem(res, 413, 'media.object.too_large', 'Single-part uploads are limited; send multipart: true');seq += 1;@@@ -173,7 +174,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {const sub = tail.slice(1);// PUT /objects/:id/content-- if (!sub.length && req.method === 'PUT') {++ if (sub.length === 1 && sub[0] === 'content' && req.method === 'PUT') {if (obj.lifecycle_status !== 'uploading') return problem(res, 409, 'media.object.not_uploading', `Object is ${obj.lifecycle_status}`);if (raw.length !== Number(obj.size_bytes)) return problem(res, 400, 'media.object.size_mismatch', `Declared ${obj.size_bytes} bytes, received ${raw.length}`);obj.bytes = raw;@@@ -1,8 +1,8 @@'use strict';--// deploy/nginx/openvibe.download.conf: openvibe.pics is still parked (302 to openvibe.network).--// openvibe.video is now OpenVibe.Video, served by the same app on the same port, chosen by Host--// header: its apex HTTPS block proxies to 127.0.0.1:4990 exactly like openvibe.download, and its--// HTTP and www blocks redirect to the apex. Each host keeps its own certificate.++// deploy/nginx/openvibe.download.conf: openvibe.video (OpenVibe.Video) and openvibe.pics (OpenVibe.Pics, 2026-10-15) are++// served by the same app on the same port as openvibe.download, chosen by Host header: each apex HTTPS block proxies to++// 127.0.0.1:4990, and each host's HTTP and www blocks redirect to its apex. Nothing is parked any more. Each host keeps++// its own certificate. Pics takes a picture (21 MB) only on its two upload paths; everything else stays at 128 KB.const assert = require('assert');const fs = require('fs');const path = require('path');@@@ -10,36 +10,35 @@ const path = require('path');const conf = fs.readFileSync(path.join(__dirname, '..', ...'deploy/nginx/openvibe.download.conf'.split('/')), 'utf8');const blocks = [...conf.matchAll(/(?:^|\n)server \{([\s\S]*?)\n\}/g)].map((m) => m[1]);const named = (host) => blocks.filter((b) => new RegExp(`server_name [^;]*\\b${host.replace(/\./g, '\\.')}\\b`).test(b));++const esc = (host) => host.replace(/\./g, '\\.');--// openvibe.pics: parked. Its HTTP block and apex/www HTTPS blocks all answer 302 to the network.--{-- const parked = named('openvibe.pics');-- assert.ok(parked.length >= 3, 'openvibe.pics: the HTTP block and the apex and www HTTPS blocks are there');-- for (const b of parked) {-- assert.match(b, /return 302 https:\/\/openvibe\.network\/;/, 'openvibe.pics: redirects to openvibe.network');-- assert.doesNotMatch(b, /proxy_pass/, 'openvibe.pics: proxies nothing');++for (const host of ['openvibe.video', 'openvibe.pics']) {++ const all = named(host);++ assert.ok(all.length >= 3, `${host}: the HTTP block and the apex and www HTTPS blocks are there`);++ const apex = all.find((b) => /listen 443 ssl;/.test(b) && new RegExp(`server_name ${esc(host)};`).test(b));++ const www = all.find((b) => /listen 443 ssl;/.test(b) && new RegExp(`server_name www\\.${esc(host)};`).test(b));++ const plain = all.find((b) => /listen 80;/.test(b));++ assert.ok(apex && www && plain, `${host}: apex, www and HTTP blocks are there`);++ assert.match(apex, /proxy_pass http:\/\/127\.0\.0\.1:4990;/, `${host} apex proxies to the app`);++ assert.match(www, new RegExp(`return 301 https://${esc(host)}\\$request_uri;`), `${host} www redirects to apex`);++ assert.match(plain, new RegExp(`return 301 https://${esc(host)}\\$request_uri;`), `${host} HTTP redirects to apex`);++ assert.doesNotMatch(apex, /return 302 https:\/\/openvibe\.network\/;/, `${host} apex is not parked`);++ assert.match(apex, /location ~\* \^\/internal\(\/\|\$\) \{ return 404; \}/, `${host}: /internal is not proxied`);++ for (const b of all.filter((x) => /listen 443 ssl;/.test(x))) {++ assert.match(b, new RegExp(`ssl_certificate\\s+/etc/letsencrypt/live/${esc(host)}/fullchain\\.pem;`), `${host}: its own certificate`);}}--// openvibe.video: no longer parked. Its apex HTTPS block proxies to the app; www and HTTP redirect to apex.++// openvibe.pics: a picture only where one is uploaded.{-- const video = named('openvibe.video');-- assert.ok(video.length >= 3, 'openvibe.video: the HTTP block and the apex and www HTTPS blocks are there');-- const apex = video.find((b) => /listen 443 ssl;/.test(b) && /server_name openvibe\.video;/.test(b));-- const www = video.find((b) => /listen 443 ssl;/.test(b) && /server_name www\.openvibe\.video;/.test(b));-- const http = video.find((b) => /listen 80;/.test(b));-- assert.ok(apex && www && http, 'openvibe.video: apex, www and HTTP blocks are there');-- assert.match(apex, /proxy_pass http:\/\/127\.0\.0\.1:4990;/, 'openvibe.video apex proxies to the app');-- assert.match(www, /return 301 https:\/\/openvibe\.video\$request_uri;/, 'openvibe.video www redirects to apex');-- assert.match(http, /return 301 https:\/\/openvibe\.video\$request_uri;/, 'openvibe.video HTTP redirects to apex');-- assert.doesNotMatch(apex, /return 302 https:\/\/openvibe\.network\/;/, 'openvibe.video apex is no longer parked');--}----// Each host uses its own certificate, on both its apex and its www block.--for (const host of ['openvibe.pics', 'openvibe.video']) {-- for (const b of named(host).filter((x) => /listen 443 ssl;/.test(x))) {-- assert.match(b, new RegExp(`ssl_certificate\\s+/etc/letsencrypt/live/${host.replace(/\./g, '\\.')}/fullchain\\.pem;`), `${host}: its own certificate`);++ const apex = named('openvibe.pics').find((b) => /server_name openvibe\.pics;/.test(b));++ assert.match(apex, /\n client_max_body_size 128k;/, 'pics: 128 KB by default');++ for (const loc of ['= /api/v1/images', '= /upload']) {++ const m = new RegExp(`location ${loc.replace(/[/.]/g, '\\$&')} \\{([\\s\\S]*?)\\n \\}`).exec(apex);++ assert.ok(m, `pics: location ${loc}`);++ assert.match(m[1], /client_max_body_size 21m;/, `pics: ${loc} takes a 20 MB picture`);++ assert.match(m[1], /limit_req zone=media-hub_writes/, `pics: ${loc} is rate-limited`);}}--console.log('nginx: pics parked (302 openvibe.network), video proxies to 127.0.0.1:4990, own certificates');++console.log('nginx: video and pics proxy to 127.0.0.1:4990 with their own certificates; pics takes pictures only on its upload paths');nnew file mode 100644@@@ -0,0 +1,240 @@++'use strict';++/**++ * OpenVibe.Pics (server/pics) on Host: openvibe.pics. A person uploads a picture (the API or the plain form); it is++ * re-encoded with its metadata gone, stored as private Media objects and served from /i/<slug>.<ext>; its page has++ * the links to share and embed. Others cannot change it; reports hide it (a child-safety report at once); a staff++ * takedown deletes it and blocks the same picture or a near-duplicate from coming back. Albums. Signed-out uploads++ * only when MEDIAHUB_PICS_ANONYMOUS=1, with a delete key and the idle sweep. The drive and Pics never answer on each++ * other's host.++ */++const assert = require('assert');++const http = require('http');++const sharp = require('sharp');++const { boot, check, done } = require('./helpers/boot');++++/**++ * t.get with a Host header (Node's fetch drops one, so this is http.request): the same options — as, json, form, body,++ * headers, method — and the same answer shape.++ */++function hostClient(t) {++ return (p, o = {}) => new Promise((resolve, reject) => {++ const headers = { Host: 'openvibe.pics', ...(o.headers || {}) };++ if (o.as) headers.cookie = t.signIn(o.as);++ let body = o.body;++ if (o.json !== undefined) { body = Buffer.from(JSON.stringify(o.json)); headers['content-type'] = 'application/json'; }++ if (o.form !== undefined) { body = Buffer.from(new URLSearchParams(o.form).toString()); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ if (body != null) headers['content-length'] = String(Buffer.byteLength(body));++ const u = new URL(t.base + p);++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: o.method || (body != null ? 'POST' : 'GET'), headers }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => {++ const buf = Buffer.concat(chunks);++ const text = buf.toString('utf8');++ resolve({ status: res.statusCode, headers: { get: (k) => res.headers[String(k).toLowerCase()] || null }, text, buffer: buf, json() { return JSON.parse(text); } });++ });++ });++ req.on('error', reject);++ if (body != null) req.write(body);++ req.end();++ });++}++++const PICS = { Host: 'openvibe.pics' };++const SAME = { ...PICS, 'sec-fetch-site': 'same-origin' };++++const photo = (bg = '#335', w = 1200, h = 800) => sharp({ create: { width: w, height: h, channels: 3, background: bg } })++ .composite([{ input: Buffer.from(`<svg width="${w}" height="${h}"><rect width="${w}" height="${h / 2}" fill="#f80"/><circle cx="${w * 0.66}" cy="${h * 0.62}" r="${h / 4}" fill="#eee"/><rect x="${w / 12}" y="${h * 0.56}" width="${w / 4}" height="${h / 3}" fill="#2a2"/></svg>`) }]);++++function multipart(fields, file) {++ const b = `----ovp${Date.now()}`;++ const parts = Object.entries(fields).map(([k, v]) => Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));++ parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="file"; filename="${file.name}"\r\nContent-Type: ${file.type}\r\n\r\n`), file.buffer, Buffer.from(`\r\n--${b}--\r\n`));++ return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${b}` };++}++++(async () => {++ const jpegWithSecrets = await photo().withMetadata({ orientation: 6, exif: { IFD0: { Make: 'SecretCam', Copyright: 'someone' }, IFD3: { GPSLatitudeRef: 'N', GPSLatitude: '40/1 45/1 0/1' } } }).jpeg({ quality: 92 }).toBuffer();++ const t0 = await boot();++ const t = { ...t0, get: hostClient(t0) };++ try {++ const kim = t.network.addUser('kim');++ const lee = t.network.addUser('lee');++ const ana = t.network.addUser('ana');++ const boss = t.network.addUser('boss', { role: 'admin' });++ const up = (as, buf, { type = 'image/jpeg', title = '' } = {}) => t.get('/api/v1/images', { as, method: 'POST', body: buf, headers: { ...SAME, 'content-type': type, ...(title ? { 'x-title': encodeURIComponent(title) } : {}) } });++ let slug;++ let direct;++++ await check('the hosts stay apart: Pics on openvibe.pics, the drive on openvibe.download', async () => {++ const home = await t.get('/', { headers: PICS });++ assert.strictEqual(home.status, 200);++ assert.ok(home.text.includes('Share pictures.') && home.text.includes('Sign in to upload'));++ assert.strictEqual((await t.get('/files', { headers: PICS, as: kim })).status, 404, 'no drive pages on the pics host');++ assert.ok((await t.get('/', { headers: { Host: 'openvibe.download' } })).text.indexOf('Share pictures.') === -1, 'the drive host is not Pics');++ assert.strictEqual((await t.get('/robots.txt', { headers: PICS })).text.includes('Disallow: /i/'), true, 'pictures are not crawled');++ });++++ await check('signed out, uploads are refused until MEDIAHUB_PICS_ANONYMOUS is on', async () => {++ const r = await t.get('/api/v1/images', { method: 'POST', body: jpegWithSecrets, headers: { ...SAME, 'content-type': 'image/jpeg' } });++ assert.strictEqual(r.status, 401);++ assert.strictEqual(r.json().code, 'pics.sign_in_required');++ });++++ await check('an upload is re-encoded without its EXIF (GPS, camera), turned upright, and stored private in Media', async () => {++ const r = await up(kim, jpegWithSecrets, { title: 'The coast' });++ assert.strictEqual(r.status, 201, r.text);++ const b = r.json();++ slug = b.slug;++ direct = `/i/${slug}.jpg`;++ assert.match(slug, /^[2-9a-km-zA-HJ-NP-Z]{8}$/);++ assert.deepStrictEqual([b.title, b.ext, b.width, b.height, b.state], ['The coast', 'jpg', 800, 1200, 'active'], 'orientation 6 is applied: 1200×800 becomes 800×1200');++ assert.strictEqual(b.links.direct, `https://openvibe.pics${direct}`);++ assert.ok(b.links.bbcode.includes(`[img]https://openvibe.pics${direct}[/img]`));++ const objects = [...t.media.objects.values()];++ assert.ok(objects.length >= 2 && objects.every((o) => o.visibility === 'private'), 'private Media objects: only this service serves them');++++ const file = await t.get(direct, { headers: PICS });++ assert.strictEqual(file.status, 200);++ assert.strictEqual(file.headers.get('content-type'), 'image/jpeg');++ assert.strictEqual(file.headers.get('x-content-type-options'), 'nosniff');++ assert.match(file.headers.get('content-security-policy'), /sandbox/);++ assert.strictEqual(file.headers.get('cache-control'), 'public, max-age=600, s-maxage=600');++ const meta = await sharp(file.buffer).metadata();++ assert.strictEqual(meta.exif, undefined, 'no EXIF block');++ assert.ok(!file.buffer.includes(Buffer.from('SecretCam')), 'the camera name is gone');++ const thumb = await t.get(`/i/${slug}/thumb.webp`, { headers: PICS });++ assert.strictEqual(thumb.status, 200);++ assert.strictEqual((await sharp(thumb.buffer).metadata()).format, 'webp');++ assert.strictEqual((await t.get(`/i/${slug}.png`, { headers: PICS })).status, 404, 'only its own extension');++ });++++ await check('what is not a JPEG, PNG, GIF, WebP or AVIF picture is refused', async () => {++ assert.strictEqual((await up(kim, Buffer.from('<?php system($_GET[1]); ?>'.repeat(20)))).json().code, 'pics.not_an_image');++ assert.strictEqual((await up(kim, Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9"><script>alert(1)</script></svg>'), { type: 'image/svg+xml' })).json().code, 'pics.unsupported');++ assert.strictEqual((await up(kim, jpegWithSecrets.subarray(0, 4000))).json().code, 'pics.not_an_image', 'a truncated file');++ const notImage = await t.get('/api/v1/images', { as: kim, method: 'POST', body: Buffer.from('hello'), headers: { ...SAME, 'content-type': 'text/plain' } });++ assert.strictEqual(notImage.status, 415);++ });++++ await check('the picture page: the links to share and embed, noindex, and the share image', async () => {++ const page = await t.get(`/i/${slug}`, { headers: PICS });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('The coast'));++ assert.ok(page.text.includes(`value="https://openvibe.pics${direct}"`));++ assert.ok(page.text.includes('noindex'));++ assert.ok(page.text.includes(`content="https://openvibe.pics${direct}"`), 'og:image is the picture');++ assert.ok(page.text.includes('Report this picture'));++ assert.ok(!page.text.includes('Delete this picture'), 'no owner tools for a visitor');++ assert.ok((await t.get(`/i/${slug}`, { headers: PICS, as: kim })).text.includes('Delete this picture'));++ });++++ await check('the plain form uploads without JavaScript; a write from another site is refused', async () => {++ const png = await photo('#a3c').png().toBuffer();++ const form = multipart({ title: 'From the form' }, { name: 'pink.png', type: 'image/png', buffer: png });++ const r = await t.get('/upload', { as: kim, method: 'POST', body: form.body, headers: { ...SAME, 'content-type': form.type } });++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));++ assert.match(r.headers.get('location'), /^\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\?new=1$/);++ const cross = await t.get('/upload', { as: kim, method: 'POST', body: form.body, headers: { ...PICS, 'sec-fetch-site': 'cross-site', 'content-type': form.type } });++ assert.strictEqual(cross.status, 403);++ const crossApi = await t.get('/api/v1/images', { as: kim, method: 'POST', body: png, headers: { ...PICS, 'sec-fetch-site': 'cross-site', 'content-type': 'image/png' } });++ assert.strictEqual(crossApi.json().code, 'request.cross_site');++ });++++ await check('only the uploader changes or deletes a picture', async () => {++ assert.strictEqual((await t.get(`/api/v1/images/${slug}`, { as: lee, method: 'PATCH', json: { title: 'mine now' }, headers: SAME })).status, 403);++ assert.strictEqual((await t.get(`/api/v1/images/${slug}`, { as: lee, method: 'DELETE', headers: SAME })).status, 403);++ const ok = await t.get(`/api/v1/images/${slug}`, { as: kim, method: 'PATCH', json: { title: 'The coast at dusk' }, headers: SAME });++ assert.strictEqual(ok.json().title, 'The coast at dusk');++ const extra = (await up(kim, await photo('#060').jpeg().toBuffer())).json();++ const before = t.media.deleted.length;++ assert.strictEqual((await t.get(`/api/v1/images/${extra.slug}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204);++ assert.strictEqual(t.media.deleted.length - before, 2, 'the picture and its thumbnail are deleted in Media');++ assert.strictEqual((await t.get(`/i/${extra.slug}.jpg`, { headers: PICS })).status, 404);++ });++++ await check('three reports hide a picture: 404 for everyone but its uploader and staff', async () => {++ const pic = (await up(kim, await photo('#909').jpeg().toBuffer(), { title: 'Reported' })).json();++ const report = (as, extra = {}) => t.get(`/api/v1/images/${pic.slug}/reports`, { as, json: { reason: 'spam' }, headers: { ...SAME, ...extra } });++ assert.strictEqual((await report(lee)).status, 201);++ assert.strictEqual((await report(lee)).json().already, true, 'one report per person');++ assert.strictEqual((await report(ana)).json().hidden, false);++ const third = await report(null, { 'x-forwarded-for': '203.0.113.7' });++ assert.strictEqual(third.json().hidden, true, 'a signed-out reporter counts by address');++ assert.strictEqual((await t.get(`/i/${pic.slug}.jpg`, { headers: PICS })).status, 404);++ assert.strictEqual((await t.get(`/i/${pic.slug}`, { headers: PICS })).status, 404);++ const own = await t.get(`/i/${pic.slug}`, { headers: PICS, as: kim });++ assert.ok(own.status === 200 && own.text.includes('hidden while staff look'));++ const staffView = await t.get(`/i/${pic.slug}.jpg`, { headers: PICS, as: boss });++ assert.strictEqual(staffView.status, 200, 'staff can see what they review');++ assert.strictEqual(staffView.headers.get('cache-control'), 'private, no-store');++ assert.ok((await t.get('/staff', { headers: PICS, as: boss })).text.includes('Reported'));++ assert.strictEqual((await t.get('/staff', { headers: PICS, as: lee })).status, 404, 'the queue is staff only');++ assert.strictEqual((await t.get(`/staff/i/${pic.slug}`, { headers: SAME, as: boss, form: { action: 'restore' } })).status, 303);++ assert.strictEqual((await t.get(`/i/${pic.slug}.jpg`, { headers: PICS })).status, 200, 'restored');++ });++++ await check('a child-safety report hides at once; a takedown deletes it and blocks the same picture and near-copies', async () => {++ const original = await photo('#c22', 1000, 1000).jpeg({ quality: 90 }).toBuffer();++ const pic = (await up(lee, original, { title: 'Bad' })).json();++ const r = await t.get(`/api/v1/images/${pic.slug}/reports`, { as: ana, json: { reason: 'child_safety', note: 'please look' }, headers: SAME });++ assert.strictEqual(r.json().hidden, true);++ const before = t.media.deleted.length;++ assert.strictEqual((await t.get(`/staff/i/${pic.slug}`, { headers: SAME, as: boss, form: { action: 'takedown', reason: 'csam' } })).status, 303);++ assert.strictEqual(t.media.deleted.length - before, 2, 'the bytes are deleted');++ assert.strictEqual((await t.get(`/i/${pic.slug}`, { headers: PICS })).status, 410);++ assert.strictEqual((await t.get(`/i/${pic.slug}.jpg`, { headers: PICS })).status, 410);++ assert.strictEqual((await up(kim, original)).json().code, 'pics.blocked', 'the same file');++ const resized = await sharp(original).resize(700).webp({ quality: 70 }).toBuffer();++ assert.strictEqual((await up(kim, resized, { type: 'image/webp' })).json().code, 'pics.blocked', 'a resized, re-compressed copy');++ assert.strictEqual((await up(kim, await sharp(original).flop().jpeg().toBuffer())).status, 201, 'a different picture is fine');++ });++++ await check('albums: made by a person, shown to anyone with the link, only the owner adds and removes', async () => {++ const made = await t.get('/api/v1/albums', { as: kim, json: { title: 'Holiday' }, headers: SAME });++ assert.strictEqual(made.status, 201);++ const album = made.json();++ assert.strictEqual((await t.get(`/api/v1/albums/${album.slug}/images`, { as: kim, json: { image: slug }, headers: SAME })).status, 204);++ const leePic = (await up(lee, await photo('#077').jpeg().toBuffer())).json();++ assert.strictEqual((await t.get(`/api/v1/albums/${album.slug}/images`, { as: kim, json: { image: leePic.slug }, headers: SAME })).status, 422, 'not your picture');++ assert.strictEqual((await t.get(`/api/v1/albums/${album.slug}/images`, { as: lee, json: { image: leePic.slug }, headers: SAME })).status, 404, 'not your album');++ const view = await t.get(`/a/${album.slug}`, { headers: PICS });++ assert.ok(view.status === 200 && view.text.includes('Holiday') && view.text.includes(`/i/${slug}/thumb.webp`));++ assert.strictEqual((await t.get(`/api/v1/albums/${album.slug}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204);++ assert.strictEqual((await t.get(`/i/${slug}`, { headers: PICS })).status, 200, 'the pictures stay');++ });++++ await check('your pictures and the API list: newest first, with your usage', async () => {++ const r = await t.get('/api/v1/me/images', { as: kim, headers: PICS });++ assert.ok(r.json().images.length >= 2 && r.json().usage.images >= 2);++ assert.strictEqual((await t.get('/mine', { headers: PICS })).status, 401);++ assert.ok((await t.get('/mine', { headers: PICS, as: kim })).text.includes('The coast at dusk'));++ });++ } finally { await t0.close(); }++++ // ── Signed-out uploads, when the owner turns them on ──++ let clock = Date.parse('2026-10-15T12:00:00Z');++ const t20 = await boot({ env: { MEDIAHUB_PICS_ANONYMOUS: '1', MEDIAHUB_PICS_ANONYMOUS_PER_DAY: '2' }, now: () => clock });++ const t2 = { ...t20, get: hostClient(t20) };++ try {++ await check('signed out (when on): a delete key shown once, kept as a hash, a daily limit, and the idle sweep', async () => {++ const anon = (buf) => t2.get('/api/v1/images', { method: 'POST', body: buf, headers: { ...SAME, 'content-type': 'image/jpeg', 'x-forwarded-for': '198.51.100.4' } });++ const r = await anon(await photo('#246').jpeg().toBuffer());++ assert.strictEqual(r.status, 201, r.text);++ const b = r.json();++ assert.ok(b.delete_key && b.delete_url.includes(b.delete_key));++ assert.ok(!(await t2.dbDump()).includes(b.delete_key), 'only the key\'s hash is stored');++ assert.strictEqual((await t2.get(`/api/v1/images/${b.slug}`, { method: 'DELETE', headers: { ...SAME, 'x-delete-key': 'wrong' } })).status, 403);++ const page = await t2.get(`/i/${b.slug}/delete?key=${b.delete_key}`, { headers: PICS });++ assert.ok(page.status === 200 && page.text.includes('Delete it for good'), 'the delete link is a page; a GET never deletes');++ assert.strictEqual((await t2.get(`/api/v1/images/${b.slug}`, { method: 'DELETE', headers: { ...SAME, 'x-delete-key': b.delete_key } })).status, 204);++++ const kept = (await anon(await photo('#642').jpeg().toBuffer())).json();++ assert.strictEqual((await anon(await photo('#426').jpeg().toBuffer())).json().code, 'pics.daily_limit');++ clock += 31 * 86_400_000;++ assert.strictEqual(await t2.ctx.pics.sweep(), 1, 'a signed-out picture nobody viewed for 30 days goes');++ assert.strictEqual((await t2.get(`/i/${kept.slug}`, { headers: PICS })).status, 404);++ });++ } finally { await t20.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -66,3 +66,19 @@ MEDIAHUB_LIVE_PUBLIC_URL=https://openvibe.live
@@ -66,3 +66,19 @@ MEDIAHUB_LIVE_PUBLIC_URL=https://openvibe.live
MEDIAHUB_CHAT_URL=https://openvibe.live
MEDIAHUB_CHAT_URL=https://openvibe.live
# IndexNow for openvibe.video (8–128 letters and digits): new watch pages are sent to search engines. Unset: off.
# IndexNow for openvibe.video (8–128 letters and digits): new watch pages are sent to search engines. Unset: off.
#INDEXNOW_KEY=
#INDEXNOW_KEY=
+
+# ── OpenVibe.Pics (openvibe.pics, server/pics) ──
+# Served by Host header on openvibe.pics. Pictures are private Media objects served through this process.
+MEDIAHUB_PICS_URL=https://openvibe.pics
+#MEDIAHUB_PICS_MAX_IMAGE_BYTES=20971520
+#MEDIAHUB_PICS_MAX_PIXELS=100000000
+#MEDIAHUB_PICS_QUOTA_BYTES=5368709120
+#MEDIAHUB_PICS_IMAGES_PER_DAY=200
+#MEDIAHUB_PICS_ALBUMS_MAX=200
+#MEDIAHUB_PICS_ALBUM_IMAGES_MAX=500
+#MEDIAHUB_PICS_REPORTS_TO_HIDE=3
+# Signed-out uploads stay off (unset) until the DMCA designated agent and the CSAM hash-matching account exist.
+#MEDIAHUB_PICS_ANONYMOUS=1
+#MEDIAHUB_PICS_ANONYMOUS_MAX_IMAGE_BYTES=10485760
+#MEDIAHUB_PICS_ANONYMOUS_PER_DAY=20
+#MEDIAHUB_PICS_ANONYMOUS_IDLE_DAYS=30
@@ -2,9 +2,9 @@
@@ -2,9 +2,9 @@
> Your files, shared on your terms.
> Your files, shared on your terms.
-**Status:** alpha. One process serves **openvibe.download** (a private drive, live in v1) and
-**openvibe.video** (public watch pages and playlists for OpenVibe.Live's stream recordings and clips), chosen by Host
-header. **openvibe.pics** is still parked: it redirects to `https://openvibe.network/` until Pics is built.
+**Status:** alpha. One process serves **openvibe.download** (a private drive, live in v1), **openvibe.video** (public
+watch pages and playlists for OpenVibe.Live's stream recordings and clips) and **openvibe.pics** (picture hosting,
+2026-10-15), chosen by Host header.
**Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB` · **License:** AGPL-3.0.
**Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB` · **License:** AGPL-3.0.
## Purpose
## Purpose
@@ -13,8 +13,7 @@ OpenVibe.MediaHub is the network's file service: it serves **openvibe.download**
@@ -13,8 +13,7 @@ OpenVibe.MediaHub is the network's file service: it serves **openvibe.download**
signed-in person can upload, organise, share and delete their own files. The bytes live in OpenVibe.Media; MediaHub
signed-in person can upload, organise, share and delete their own files. The bytes live in OpenVibe.Media; MediaHub
keeps the metadata, the per-person limits, the share links and the report queue. It also answers OpenVibe.Network's
keeps the metadata, the per-person limits, the share links and the report queue. It also answers OpenVibe.Network's
account export and deletion events (ADR-033). The same process serves **openvibe.video**, the public watch pages for
account export and deletion events (ADR-033). The same process serves **openvibe.video**, the public watch pages for
-OpenVibe.Live's stream recordings and clips (OpenVibe.Video v1, step A). openvibe.pics is parked in nginx and redirects
-to openvibe.network.
+OpenVibe.Live's stream recordings and clips (OpenVibe.Video v1, step A), and **openvibe.pics**, OpenVibe.Pics (below).
## Owns
## Owns
@@ -33,6 +32,10 @@ creates none):
@@ -33,6 +32,10 @@ creates none):
(openvibe-sdk/account-data, ADR-033).
(openvibe-sdk/account-data, ADR-033).
- `video_playlists` and `video_playlist_items` — the person's playlists and their ordered Live items
- `video_playlists` and `video_playlist_items` — the person's playlists and their ordered Live items
(`migrations/0003_video_playlists.sql`); the items cascade from their playlist.
(`migrations/0003_video_playlists.sql`); the items cascade from their playlist.
+- `pics_images`, `pics_albums`, `pics_album_images`, `pics_reports`, `pics_blocklist`, `pics_usage`
+ (`migrations/0006_pics.sql`) — OpenVibe.Pics: each picture (its Media object and thumbnail, size, hashes, state and
+ report count; a signed-out upload's delete key only as its sha256), albums and their order, one report per reporter
+ per picture, the takedown blocklist (sha256 and difference hashes) and uploads per uploader per day.
- `telemetry_samples` (`migrations/0005_telemetry.sql`) — requests aggregated by route template, method and status class,
- `telemetry_samples` (`migrations/0005_telemetry.sql`) — requests aggregated by route template, method and status class,
never by raw URL; `/shared`, static assets, health, readiness, `/metrics` not counted; one `http.request` sample per key each
never by raw URL; `/shared`, static assets, health, readiness, `/metrics` not counted; one `http.request` sample per key each
minute plus active-requests, p95 latency and event-loop-lag gauges (openvibe-sdk 0.46, `platform.telemetry-sample@1`); 14-day
minute plus active-requests, p95 latency and event-loop-lag gauges (openvibe-sdk 0.46, `platform.telemetry-sample@1`); 14-day
@@ -46,8 +49,8 @@ creates none):
@@ -46,8 +49,8 @@ creates none):
`identity.subject.resolve`.
`identity.subject.resolve`.
- Event delivery, and the account export/deletion topics, are **OpenVibe.Events**': MediaHub only creates its
- Event delivery, and the account export/deletion topics, are **OpenVibe.Events**': MediaHub only creates its
subscriptions and consumes the two deliveries.
subscriptions and consumes the two deliveries.
-- The **openvibe.pics** product (image hosting and albums) is not built here yet: that domain is parked in nginx.
- Previews and thumbnails are not built either.
+- **CSAM hash-matching against known material** (PhotoDNA, NCMEC hash lists): that needs the owner's NCMEC / Cloudflare
+ CSAM Scanning Tool account. Pics matches only its own takedown blocklist, and child-safety reports hide at once.
## Depends on
## Depends on
@@ -122,6 +125,51 @@ calls Media server-side only, naming the person with `X-OV-Subject`, and never h
@@ -122,6 +125,51 @@ calls Media server-side only, naming the person with `X-OV-Subject`, and never h
MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `mh_usage` (migrations/0002_mediahub.sql).
MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `mh_usage` (migrations/0002_mediahub.sql).
+## OpenVibe.Pics (openvibe.pics)
+
+An open, ad-free picture host: drop a picture, get a link to share or embed. Built in [server/pics/](server/pics/).
+
+- **Upload:** a signed-in person uploads JPEG, PNG, GIF, WebP or AVIF (20 MB, 100 megapixels): the plain form at `/`
+ works without JavaScript; [public/js/pics.js](public/js/pics.js) adds drag-and-drop, paste and several files at once
+ through the API. Limits: 200 a day, 5 GB a person (`MEDIAHUB_PICS_*`).
+- **Every picture is re-encoded** ([server/pics/images.js](server/pics/images.js), sharp/libvips): it must decode as
+ one of those formats (a renamed script, an SVG or a truncated file is refused), the EXIF orientation is applied and
+ every metadata block is dropped (GPS position, camera serial, XMP, IPTC), animations keep their frames, colour goes
+ to sRGB, and a 640-pixel WebP thumbnail is made. Nothing is ever served as it was uploaded.
+- **Storage and serving:** the picture and its thumbnail are **private** OpenVibe.Media objects (`MEDIAHUB_MEDIA_APP`).
+ `/i/<slug>.<ext>` (the direct link) and `/i/<slug>/thumb.webp` read them from Media and serve them with `nosniff`, a
+ sandboxing CSP, `Access-Control-Allow-Origin: *` and a 10-minute public cache, so the Media id never reaches a
+ browser and a hidden picture leaves shared caches within minutes.
+- **Unlisted:** `/i/<slug>` (8 random characters) is reachable by its link only, noindex, never listed; robots.txt
+ disallows `/i/` and `/a/`. The page has the links to share and embed: the page, the direct link, Markdown, BBCode
+ and HTML.
+- **Albums** (`/albums`, `/a/<slug>`): a person's own, shown to anyone with the link (hidden pictures left out).
+- **Reports and takedown:** anyone may report a picture once (signed out: counted by a keyed hash of the address).
+ A child-safety report hides it at once; any other reason hides it at 3 distinct reports. Hidden is 404 for everyone
+ but its uploader and staff. Staff (`/staff`, Network `admin`/`global_mod`) restore, hide, or take down: a takedown
+ deletes the bytes in Media and puts the picture's sha256 (uploaded and served) and its 64-bit difference hash on
+ `pics_blocklist`, so the same picture, or a resized, re-compressed or retouched copy (within 10 bits: measured on
+ real photos, such copies land 0–10 bits away and different photos 23–42), cannot be uploaded again.
+- **Signed out** (`MEDIAHUB_PICS_ANONYMOUS=1`, **off**): uploads up to 10 MB, 20 a day per address, each with a delete
+ key shown once and stored only as its sha256; a picture nobody has viewed for 30 days is deleted by an hourly sweep.
+ It stays off until the owner registers the DMCA designated agent and the CSAM hash-matching account (NCMEC /
+ Cloudflare CSAM Scanning Tool): plan T10's gate.
+- **Legal:** `/terms`, `/privacy` and `/dmca` name openvibe.pics (openvibe-shared/legal, the `ugc` profile).
+
+API on the pics host (`/api/v1`, a person; app, agent and service tokens are refused):
+
+| Route | |
+|---|---|
+| `POST /images` | upload: the picture is the body (`Content-Type: image/*`; optional `X-Title`, `X-Filename`, URI-encoded); 201 with the links |
+| `GET /images/:slug` | metadata and links (hidden: uploader and staff only; 410 when taken down) |
+| `PATCH /images/:slug` | the uploader: `{ title, description }` |
+| `DELETE /images/:slug` | the uploader (signed out: `X-Delete-Key`) |
+| `POST /images/:slug/reports` | anyone: `{ reason, note }`; `reason` one of `child_safety`, `illegal`, `copyright`, `sexual`, `violence`, `harassment`, `private_info`, `spam`, `other` |
+| `GET /me/images` | your pictures, newest first, with your usage |
+| `GET /albums`, `POST /albums` | your albums; create one `{ title, description }` |
+| `GET /albums/:slug` | an album and its pictures |
+| `POST /albums/:slug/images`, `DELETE /albums/:slug/images/:image`, `DELETE /albums/:slug` | the owner adds or takes out a picture, or deletes the album (the pictures stay) |
+
## OpenVibe.Video (openvibe.video)
## OpenVibe.Video (openvibe.video)
OpenVibe.Video is public and read-only: it serves watch pages for the recordings and clips people already made
OpenVibe.Video is public and read-only: it serves watch pages for the recordings and clips people already made
@@ -279,14 +327,17 @@ are Live ids only; the watch pages resolve their display fields lazily through t
@@ -279,14 +327,17 @@ are Live ids only; the watch pages resolve their display fields lazily through t
takes `network.account.export_requested` and `network.account.deleted`, and the subscriptions are created at boot
takes `network.account.export_requested` and `network.account.deleted`, and the subscriptions are created at boot
(`MEDIAHUB_EVENTS_URL`).
(`MEDIAHUB_EVENTS_URL`).
-- **Export:** the person's files, folders, shares, the reports they made and their usage.
-- **Deletion:** all of it is erased, and each object is deleted in Media too.
+- **Export:** the person's files, folders, shares, the reports they made and their usage; their playlists; and their
+ OpenVibe.Pics pictures, albums (with their pictures' order), the reports they filed and their upload counts.
+- **Deletion:** all of it is erased, and each object (a file, a picture and its thumbnail) is deleted in Media too.
+ The takedown blocklist is staff's record, not the person's, and stays.
## Configuration
## Configuration
Every name is in [.env.example](.env.example): the Network client (`OV_OAUTH_CLIENT_ID=media-hub`, secret, internal
Every name is in [.env.example](.env.example): the Network client (`OV_OAUTH_CLIENT_ID=media-hub`, secret, internal
URL), PostgreSQL and Valkey (written by the host's data role), Media (`MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`),
URL), PostgreSQL and Valkey (written by the host's data role), Media (`MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`),
-Live (`MEDIAHUB_LIVE_URL`, `MEDIAHUB_LIVE_PUBLIC_URL`) and Video (`MEDIAHUB_VIDEO_URL`),
+Live (`MEDIAHUB_LIVE_URL`, `MEDIAHUB_LIVE_PUBLIC_URL`), Video (`MEDIAHUB_VIDEO_URL`), Pics (`MEDIAHUB_PICS_URL` and the
+`MEDIAHUB_PICS_*` limits, `MEDIAHUB_PICS_ANONYMOUS`),
Events, and the limits (`MEDIAHUB_QUOTA_BYTES`, `MEDIAHUB_MAX_FILE_BYTES`,
Events, and the limits (`MEDIAHUB_QUOTA_BYTES`, `MEDIAHUB_MAX_FILE_BYTES`,
`MEDIAHUB_UPLOADS_PER_DAY`, `MEDIAHUB_MAX_SHARES`, `MEDIAHUB_SINGLE_UPLOAD_BYTES`, `MEDIAHUB_PART_BYTES`).
`MEDIAHUB_UPLOADS_PER_DAY`, `MEDIAHUB_MAX_SHARES`, `MEDIAHUB_SINGLE_UPLOAD_BYTES`, `MEDIAHUB_PART_BYTES`).
@@ -153,37 +153,110 @@ server {
@@ -153,37 +153,110 @@ server {
}
}
}
}
-# ── openvibe.pics: parked. It redirects to openvibe.network until Pics is built. ──
-# No app is behind this host: every request is answered here with a 302, and nothing is proxied.
+# ── openvibe.pics: OpenVibe.Pics, the same app as openvibe.download, chosen by Host header. ──
server {
server {
listen 80;
listen 80;
listen [::]:80;
listen [::]:80;
server_name openvibe.pics www.openvibe.pics;
server_name openvibe.pics www.openvibe.pics;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location /.well-known/acme-challenge/ { root /var/www/certbot; }
- location / { return 302 https://openvibe.network/; }
+ location / { return 301 https://openvibe.pics$request_uri; }
}
}
server {
server {
listen 443 ssl;
listen 443 ssl;
listen [::]:443 ssl;
listen [::]:443 ssl;
http2 on;
http2 on;
- server_name openvibe.pics;
+ server_name www.openvibe.pics;
+
ssl_certificate /etc/letsencrypt/live/openvibe.pics/fullchain.pem;
ssl_certificate /etc/letsencrypt/live/openvibe.pics/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.pics/privkey.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.pics/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_protocols TLSv1.2 TLSv1.3;
- return 302 https://openvibe.network/;
+
+ return 301 https://openvibe.pics$request_uri;
}
}
server {
server {
listen 443 ssl;
listen 443 ssl;
listen [::]:443 ssl;
listen [::]:443 ssl;
http2 on;
http2 on;
- server_name www.openvibe.pics;
+ server_name openvibe.pics;
+
+ include snippets/security-txt.conf; # /.well-known/security.txt (host file; SECURITY.md in every repo)
ssl_certificate /etc/letsencrypt/live/openvibe.pics/fullchain.pem;
ssl_certificate /etc/letsencrypt/live/openvibe.pics/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.pics/privkey.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.pics/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_protocols TLSv1.2 TLSv1.3;
- return 302 https://openvibe.network/;
+
+ access_log /var/log/nginx/openvibe.pics.access.log;
+ error_log /var/log/nginx/openvibe.pics.error.log;
+
+ # Forms and the API's JSON are small; only the two upload paths below take a picture (20 MB, MEDIAHUB_PICS_MAX_IMAGE_BYTES).
+ client_max_body_size 128k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ location = /auth/me {
+ limit_req zone=media-hub_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4990;
+ }
+
+ location /auth/ {
+ limit_req zone=media-hub_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4990;
+ }
+
+ # A picture upload: the API (JavaScript, one picture per request) and the plain form. Streamed to the app, which
+ # re-encodes it; nothing is buffered to disk here.
+ location = /api/v1/images {
+ limit_req zone=media-hub_writes burst=20 nodelay;
+ limit_req_status 429;
+ client_max_body_size 21m;
+ proxy_request_buffering off;
+ proxy_pass http://127.0.0.1:4990;
+ }
+
+ location = /upload {
+ limit_req zone=media-hub_writes burst=10 nodelay;
+ limit_req_status 429;
+ client_max_body_size 21m;
+ proxy_request_buffering off;
+ proxy_pass http://127.0.0.1:4990;
+ }
+
+ location /api/ {
+ limit_req zone=media-hub_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4990;
+ }
+
+ location ~* ^/internal(/|$) { return 404; }
+ location ~* ^/metrics(/|$) { return 404; }
+
+ # The pages and the picture files (/i/<slug>.<ext>, served by the app with a 10-minute public cache).
+ location / {
+ proxy_pass http://127.0.0.1:4990;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
}
}
# ── openvibe.video: OpenVibe.Video, the same app as openvibe.download, chosen by Host header. ──
# ── openvibe.video: OpenVibe.Video, the same app as openvibe.download, chosen by Host header. ──
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+-- phase: expand
+-- OpenVibe.Pics (server/pics): pictures on openvibe.pics, their albums, the reports people file and the hashes staff
+-- took down. The bytes are OpenVibe.Media objects (unlisted: anyone with the link); MediaHub keeps what they are,
+-- who uploaded them and whether they may be shown. Nothing here is a credential, except that a signed-out upload's
+-- delete key is kept only as its sha256.
+
+CREATE TABLE pics_images (
+ id text COLLATE "C" PRIMARY KEY, -- pic_<ULID>
+ slug text COLLATE "C" NOT NULL UNIQUE, -- the 8-character name in /i/<slug>
+ owner text COLLATE "C", -- user:usr_…, or NULL for a signed-out upload
+ delete_key_hash text COLLATE "C", -- sha256 of a signed-out upload's delete key
+ title text NOT NULL DEFAULT '', -- <= 120 characters (enforced in code)
+ description text NOT NULL DEFAULT '', -- <= 2,000 characters (enforced in code)
+ object_id text COLLATE "C" NOT NULL, -- med_… the picture, re-encoded without its metadata
+ thumb_object_id text COLLATE "C", -- med_… a 640-pixel WebP of it (the first frame if animated)
+ mime_type text COLLATE "C" NOT NULL,
+ ext text COLLATE "C" NOT NULL, -- jpg | png | gif | webp | avif
+ width integer NOT NULL,
+ height integer NOT NULL,
+ bytes bigint NOT NULL,
+ animated boolean NOT NULL DEFAULT false,
+ sha256 text COLLATE "C" NOT NULL, -- of the bytes served
+ source_sha256 text COLLATE "C" NOT NULL, -- of the bytes uploaded
+ dhash text COLLATE "C" NOT NULL, -- 64-bit difference hash, 16 hex digits (near-duplicates)
+ state text COLLATE "C" NOT NULL DEFAULT 'active',
+ report_count integer NOT NULL DEFAULT 0,
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ last_viewed_at text COLLATE "C",
+ CHECK (state IN ('active', 'hidden', 'removed'))
+);
+CREATE INDEX pics_images_owner ON pics_images (owner, id DESC);
+CREATE INDEX pics_images_state ON pics_images (state, id DESC);
+CREATE INDEX pics_images_anonymous_idle ON pics_images (last_viewed_at) WHERE owner IS NULL;
+
+CREATE TABLE pics_albums (
+ id text COLLATE "C" PRIMARY KEY, -- alb_<ULID>
+ slug text COLLATE "C" NOT NULL UNIQUE, -- /a/<slug>
+ owner text COLLATE "C" NOT NULL, -- user:usr_… (albums are account-only)
+ title text NOT NULL,
+ description text NOT NULL DEFAULT '',
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL
+);
+CREATE INDEX pics_albums_owner ON pics_albums (owner, id DESC);
+
+CREATE TABLE pics_album_images (
+ album_id text COLLATE "C" NOT NULL REFERENCES pics_albums (id) ON DELETE CASCADE,
+ image_id text COLLATE "C" NOT NULL REFERENCES pics_images (id) ON DELETE CASCADE,
+ position integer NOT NULL,
+ added_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (album_id, image_id)
+);
+CREATE INDEX pics_album_images_order ON pics_album_images (album_id, position);
+
+-- One report per reporter per picture. A signed-out reporter is a keyed hash of their address, never the address.
+CREATE TABLE pics_reports (
+ id text COLLATE "C" PRIMARY KEY, -- prp_<ULID>
+ image_id text COLLATE "C" NOT NULL REFERENCES pics_images (id) ON DELETE CASCADE,
+ reporter text COLLATE "C" NOT NULL,
+ reason text COLLATE "C" NOT NULL,
+ note text NOT NULL DEFAULT '',
+ created_at text COLLATE "C" NOT NULL,
+ UNIQUE (image_id, reporter)
+);
+
+-- What staff took down stays down: an upload whose bytes (sha256) or near-duplicate (dhash) match is refused.
+CREATE TABLE pics_blocklist (
+ kind text COLLATE "C" NOT NULL, -- sha256 | dhash
+ value text COLLATE "C" NOT NULL,
+ reason text COLLATE "C" NOT NULL,
+ image_id text COLLATE "C", -- the picture it came from (kept after it is gone)
+ created_by text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (kind, value),
+ CHECK (kind IN ('sha256', 'dhash'))
+);
+
+-- Uploads per uploader per UTC day: a person, or a keyed hash of a signed-out address.
+CREATE TABLE pics_usage (
+ uploader text COLLATE "C" NOT NULL,
+ day text COLLATE "C" NOT NULL,
+ uploads integer NOT NULL DEFAULT 0,
+ PRIMARY KEY (uploader, day)
+);
@@ -18,7 +18,8 @@
@@ -18,7 +18,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "sharp": "^0.33.5"
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8"
"@electric-sql/pglite": "^0.5.8"
@@ -34,6 +35,413 @@
@@ -34,6 +35,413 @@
"devOptional": true,
"devOptional": true,
"license": "Apache-2.0"
"license": "Apache-2.0"
},
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
+ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
+ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
+ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
+ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
+ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
+ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
+ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
+ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
+ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
+ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
+ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.0.5"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
+ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
+ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
+ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
+ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
+ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
+ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
+ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
+ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@iovalkey/commands": {
"node_modules/@iovalkey/commands": {
"version": "0.1.0",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
@@ -169,6 +577,47 @@
@@ -169,6 +577,47 @@
"node": ">=0.10.0"
"node": ">=0.10.0"
}
}
},
},
+ "node_modules/color": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1",
+ "color-string": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=12.5.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -255,6 +704,15 @@
@@ -255,6 +704,15 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/dotenv": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -653,6 +1111,12 @@
@@ -653,6 +1111,12 @@
"node": ">= 0.10"
"node": ">= 0.10"
}
}
},
},
+ "node_modules/is-arrayish": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+ "license": "MIT"
+ },
"node_modules/json-schema-traverse": {
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -1242,6 +1706,45 @@
@@ -1242,6 +1706,45 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/sharp": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
+ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "color": "^4.2.3",
+ "detect-libc": "^2.0.3",
+ "semver": "^7.6.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.33.5",
+ "@img/sharp-darwin-x64": "0.33.5",
+ "@img/sharp-libvips-darwin-arm64": "1.0.4",
+ "@img/sharp-libvips-darwin-x64": "1.0.4",
+ "@img/sharp-libvips-linux-arm": "1.0.5",
+ "@img/sharp-libvips-linux-arm64": "1.0.4",
+ "@img/sharp-libvips-linux-s390x": "1.0.4",
+ "@img/sharp-libvips-linux-x64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
+ "@img/sharp-linux-arm": "0.33.5",
+ "@img/sharp-linux-arm64": "0.33.5",
+ "@img/sharp-linux-s390x": "0.33.5",
+ "@img/sharp-linux-x64": "0.33.5",
+ "@img/sharp-linuxmusl-arm64": "0.33.5",
+ "@img/sharp-linuxmusl-x64": "0.33.5",
+ "@img/sharp-wasm32": "0.33.5",
+ "@img/sharp-win32-ia32": "0.33.5",
+ "@img/sharp-win32-x64": "0.33.5"
+ }
+ },
"node_modules/side-channel": {
"node_modules/side-channel": {
"version": "1.1.1",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@ -1314,6 +1817,15 @@
@@ -1314,6 +1817,15 @@
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
},
},
+ "node_modules/simple-swizzle": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
"node_modules/split2": {
"node_modules/split2": {
"version": "4.2.0",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -1347,6 +1859,13 @@
@@ -1347,6 +1859,13 @@
"node": ">=0.6"
"node": ">=0.6"
}
}
},
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/type-is": {
"node_modules/type-is": {
"version": "1.6.18",
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -25,7 +25,8 @@
@@ -25,7 +25,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "sharp": "^0.33.5"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
+/* OpenVibe.Pics — openvibe.pics (server/pics/pages.js, ovp-*). Loaded after app.css (the drive's base: body type, links,
+ .page, .notice); colours come from the network theme variables, with the Pics pink as the brand accent. Filled
+ buttons are pink with near-black text (about 8:1), never white on pink. */
+
+.ovp-page {
+ --ovp-pink: #f472b6;
+ --ovp-pink-soft: #f9a8d4;
+ --ovp-on-pink: #1f0a17;
+ --ovp-accent-text: color-mix(in srgb, var(--ovp-pink) 72%, var(--text-primary, #e6edf7));
+ --ovp-card: var(--bg-card, var(--bg-secondary, #101828));
+ --ovp-line: var(--border, #1f2d47);
+ --ovp-line-strong: var(--border-light, #2c3d5c);
+ --ovp-dim: var(--text-secondary, #96a7c2);
+ --ovp-faint: var(--text-muted, #7386a3);
+ max-width: 1120px;
+ padding: 24px 24px 72px;
+}
+.ovp-page h2 { font-size: 1.15rem; margin: 0 0 .75rem; }
+
+/* Buttons */
+.ovp-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 38px; padding: 7px 14px; border-radius: 10px; border: 1px solid var(--ovp-line-strong); background: var(--bg-tertiary, #16223a); color: var(--text-primary, #e6edf7) !important; font: inherit; font-size: .9rem; font-weight: 700; text-decoration: none; cursor: pointer; }
+.ovp-btn:hover, .ovp-btn:focus-visible { border-color: var(--ovp-pink); }
+.ovp-btn--primary { background: var(--ovp-pink); border-color: var(--ovp-pink); color: var(--ovp-on-pink) !important; }
+.ovp-btn--primary:hover, .ovp-btn--primary:focus-visible { background: var(--ovp-pink-soft); border-color: var(--ovp-pink-soft); }
+.ovp-btn--danger { background: transparent; border-color: color-mix(in srgb, var(--error, #ef4444) 55%, transparent); color: var(--error, #ef4444) !important; }
+.ovp-btn--danger:hover, .ovp-btn--danger:focus-visible { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: var(--error, #ef4444); }
+
+/* Home */
+.ovp-hero { padding: 12px 0 18px; }
+.ovp-eyebrow { display: inline-block; margin: 0; padding: 4px 12px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--ovp-pink) 45%, var(--ovp-line-strong)); color: var(--ovp-accent-text); font-size: .78rem; font-weight: 700; letter-spacing: .3px; }
+.ovp-hero h1 { margin: .6rem 0 .5rem; font-size: clamp(1.9rem, 4.6vw, 3rem); line-height: 1.08; letter-spacing: -.02em; }
+.ovp-accent { background: linear-gradient(92deg, var(--ovp-pink), #c084fc 70%); -webkit-background-clip: text; background-clip: text; color: transparent; }
+.ovp-lede { margin: 0; max-width: 62ch; font-size: 1.05rem; color: var(--ovp-dim); }
+
+/* The drop zone: a plain form without JavaScript; public/js/pics.js adds drag, paste and many files. */
+.ovp-drop { position: relative; margin: 6px 0 0; border: 2px dashed color-mix(in srgb, var(--ovp-pink) 55%, var(--ovp-line-strong)); border-radius: 20px; background: radial-gradient(120% 140% at 50% 0%, color-mix(in srgb, var(--ovp-pink) 10%, transparent), transparent 60%), var(--ovp-card); transition: border-color .15s ease, background-color .15s ease; }
+.ovp-drop.is-dragover { border-color: var(--ovp-pink); border-style: solid; background-color: color-mix(in srgb, var(--ovp-pink) 10%, var(--ovp-card)); }
+.ovp-drop-inner { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 36px 20px 30px; text-align: center; }
+.ovp-drop--compact .ovp-drop-inner { padding: 20px 16px; }
+.ovp-drop--compact .ovp-drop-icon, .ovp-drop--compact .ovp-drop-hint { display: none; }
+.ovp-drop-icon { color: var(--ovp-pink); opacity: .9; }
+.ovp-drop-title { margin: 0; font-size: 1.15rem; font-weight: 700; }
+.ovp-drop-hint { margin: 0; max-width: 52ch; color: var(--ovp-dim); font-size: .92rem; }
+.ovp-file { position: relative; overflow: hidden; }
+.ovp-file input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; font-size: 0; }
+.ovp-file:focus-within { outline: 3px solid var(--ovp-pink-soft); outline-offset: 2px; }
+.ovp-drop-fields { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; font-size: .9rem; color: var(--ovp-dim); }
+.ovp-drop.is-enhanced .ovp-nojs-submit, .ovp-drop.is-enhanced .ovp-drop-fields { display: none; }
+.ovp-drop--signin .ovp-drop-inner { padding: 30px 20px; }
+
+/* Upload results (the script) */
+.ovp-results { list-style: none; margin: 0; padding: 0 16px; display: grid; gap: 10px; }
+.ovp-results:not(:empty) { padding-bottom: 16px; }
+.ovp-result { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 12px; align-items: center; padding: 10px; border-radius: 14px; border: 1px solid var(--ovp-line); background: var(--bg-secondary, #0f1729); text-align: left; }
+.ovp-result img, .ovp-result .ovp-result-ph { width: 64px; height: 64px; border-radius: 10px; object-fit: cover; background: var(--bg-tertiary, #16223a); }
+.ovp-result-main { min-width: 0; display: grid; gap: 6px; }
+.ovp-result-name { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovp-result-error { color: var(--error, #ef4444); font-size: .9rem; }
+.ovp-progress { height: 6px; border-radius: 999px; background: var(--bg-tertiary, #16223a); overflow: hidden; }
+.ovp-progress span { display: block; height: 100%; width: 0; background: var(--ovp-pink); transition: width .15s linear; }
+.ovp-result .ovp-copy-row input { font-size: .82rem; }
+
+/* Sections, grids */
+.ovp-section { margin-top: 2.2rem; }
+.ovp-section-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 16px; margin: 0 0 .9rem; }
+.ovp-section-head h1, .ovp-section-head h2 { margin: 0; }
+.ovp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; }
+.ovp-tile { position: relative; display: block; aspect-ratio: 1; overflow: hidden; border-radius: 12px; border: 1px solid var(--ovp-line); background: var(--bg-tertiary, #16223a); }
+.ovp-tile img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .2s ease; }
+.ovp-tile:hover img, .ovp-tile:focus-visible img { transform: scale(1.03); }
+.ovp-tile.is-hidden img { opacity: .45; }
+.ovp-flag { position: absolute; left: 8px; top: 8px; padding: 2px 8px; border-radius: 999px; background: rgba(0, 0, 0, .7); color: #fff; font-size: .72rem; font-weight: 700; }
+.ovp-features { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
+.ovp-feature { padding: 16px 18px; border-radius: 14px; border: 1px solid var(--ovp-line); background: var(--ovp-card); }
+.ovp-feature h3 { margin: 0 0 6px; font-size: 1rem; }
+.ovp-feature p { margin: 0; color: var(--ovp-dim); font-size: .92rem; }
+
+/* A picture */
+.ovp-picture { margin: 0; }
+.ovp-title { margin: 0 0 12px; font-size: clamp(1.4rem, 3vw, 2rem); overflow-wrap: anywhere; }
+.ovp-figure { margin: 0; display: flex; justify-content: center; border-radius: 16px; overflow: hidden; border: 1px solid var(--ovp-line);
+ background-color: var(--bg-tertiary, #16223a);
+ background-image: linear-gradient(45deg, rgba(255,255,255,.04) 25%, transparent 25%, transparent 75%, rgba(255,255,255,.04) 75%), linear-gradient(45deg, rgba(255,255,255,.04) 25%, transparent 25%, transparent 75%, rgba(255,255,255,.04) 75%);
+ background-size: 24px 24px; background-position: 0 0, 12px 12px; }
+.ovp-figure a { display: flex; max-width: 100%; }
+.ovp-figure img { display: block; max-width: 100%; max-height: 78vh; width: auto; height: auto; object-fit: contain; }
+.ovp-meta { margin: 10px 0 0; color: var(--ovp-faint); font-size: .88rem; }
+.ovp-desc { max-width: 70ch; white-space: pre-line; overflow-wrap: anywhere; }
+.ovp-share { margin-top: 1.6rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 10px 16px; }
+.ovp-share h2 { grid-column: 1 / -1; margin-bottom: 0; }
+.ovp-copy { display: grid; gap: 4px; min-width: 0; }
+.ovp-copy label { font-size: .8rem; font-weight: 700; color: var(--ovp-dim); }
+.ovp-copy-row { display: flex; gap: 6px; min-width: 0; }
+.ovp-copy-row input { flex: 1; min-width: 0; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .85rem; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--ovp-line-strong); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }
+.ovp-copy-row .ovp-btn { min-width: 74px; }
+.ovp-page > .ovp-copy { max-width: 640px; margin: 0 0 14px; }
+.ovp-key { display: inline-block; margin-top: 6px; overflow-wrap: anywhere; }
+.ovp-confirm-thumb { max-width: 240px; border-radius: 12px; }
+
+/* Forms */
+.ovp-form { display: grid; gap: 6px; max-width: 640px; margin: 0 0 14px; }
+.ovp-form label, .ovp-form legend { font-size: .85rem; font-weight: 700; color: var(--ovp-dim); }
+.ovp-page input[type=text], .ovp-page textarea, .ovp-page select { font: inherit; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--ovp-line-strong); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); max-width: 100%; }
+.ovp-page textarea { resize: vertical; }
+.ovp-inline { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; max-width: none; }
+.ovp-inline label { width: 100%; }
+.ovp-owner { padding: 16px 18px; border-radius: 16px; border: 1px solid var(--ovp-line); background: var(--ovp-card); }
+.ovp-report { margin-top: 1.6rem; }
+.ovp-report summary { cursor: pointer; color: var(--ovp-faint); font-size: .9rem; width: fit-content; }
+.ovp-report fieldset { border: 0; padding: 0; margin: 8px 0; display: grid; gap: 4px; }
+.ovp-radio { font-weight: 400 !important; color: var(--text-primary, #e6edf7) !important; display: flex; gap: 8px; align-items: center; }
+
+/* Albums */
+.ovp-albums { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; margin-top: 1rem; }
+.ovp-album { display: grid; gap: 4px; padding: 8px; border-radius: 14px; border: 1px solid var(--ovp-line); background: var(--ovp-card); text-decoration: none; color: inherit; }
+.ovp-album:hover, .ovp-album:focus-visible { border-color: var(--ovp-pink); }
+.ovp-album img, .ovp-album-empty { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 10px; background: var(--bg-tertiary, #16223a); }
+.ovp-album-title { font-weight: 700; padding: 2px 4px 0; overflow-wrap: anywhere; }
+.ovp-album .muted { padding: 0 4px; font-size: .85rem; }
+
+/* Staff */
+.ovp-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 1rem; }
+.ovp-tab { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--ovp-line-strong); text-decoration: none; color: inherit; font-size: .9rem; }
+.ovp-tab.on { border-color: var(--ovp-pink); font-weight: 700; }
+.ovp-staff { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 14px; padding: 12px 0; border-top: 1px solid var(--ovp-line); }
+.ovp-staff img, .ovp-staff .ovp-album-empty { width: 120px; aspect-ratio: 1; object-fit: cover; border-radius: 10px; }
+.ovp-staff p { margin: 0 0 6px; }
+
+@media (max-width: 640px) {
+ .ovp-page { padding: 16px 16px 56px; }
+ .ovp-grid { grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 6px; }
+ .ovp-tile { border-radius: 8px; }
+ .ovp-share { grid-template-columns: minmax(0, 1fr); }
+ .ovp-staff { grid-template-columns: 72px minmax(0, 1fr); }
+ .ovp-staff img, .ovp-staff .ovp-album-empty { width: 72px; }
+ .ovp-drop-inner { padding: 28px 14px 24px; }
+}
+@media (prefers-reduced-motion: reduce) { .ovp-tile img, .ovp-drop { transition: none; } .ovp-tile:hover img { transform: none; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+/*
+ * OpenVibe.Pics on top of the plain forms (server/pics/pages.js): drop or paste pictures, several at once, each sent
+ * to POST /api/v1/images with a progress bar, then its links to copy. Copy buttons ([data-copy]) and confirmations
+ * ([data-confirm]) anywhere on the page. Nothing here is needed: without it every form still works.
+ * DOM nodes only, never innerHTML.
+ */
+(function () {
+ 'use strict';
+
+ function el(tag, props, children) {
+ var node = document.createElement(tag);
+ Object.keys(props || {}).forEach(function (k) {
+ if (k === 'text') node.textContent = props[k];
+ else if (k === 'className') node.className = props[k];
+ else node.setAttribute(k, props[k]);
+ });
+ (children || []).forEach(function (c) { if (c) node.appendChild(c); });
+ return node;
+ }
+
+ function copyFrom(input, button) {
+ var done = function () { button.textContent = 'Copied'; setTimeout(function () { button.textContent = 'Copy'; }, 1600); };
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(input.value).then(done, function () { input.select(); });
+ } else {
+ input.select();
+ try { document.execCommand('copy'); done(); } catch (e) { /* the text is selected; the person copies it */ }
+ }
+ }
+
+ document.addEventListener('click', function (ev) {
+ var btn = ev.target.closest('[data-copy]');
+ if (!btn) return;
+ var input = document.getElementById(btn.getAttribute('data-copy'));
+ if (input) copyFrom(input, btn);
+ });
+ document.addEventListener('submit', function (ev) {
+ var msg = ev.target.getAttribute && ev.target.getAttribute('data-confirm');
+ if (msg && !window.confirm(msg)) ev.preventDefault();
+ });
+
+ var form = document.querySelector('[data-pics-upload]');
+ if (!form || !window.XMLHttpRequest || !window.FormData) return;
+ var input = form.querySelector('[data-pics-input]');
+ var results = form.querySelector('[data-pics-results]');
+ var max = Number(form.getAttribute('data-max')) || 0;
+ var TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
+ var queue = [];
+ var busy = false;
+ var seq = 0;
+
+ form.classList.add('is-enhanced');
+ input.multiple = true;
+ input.required = false;
+
+ function copyRow(label, value) {
+ var id = 'up-' + (++seq);
+ var field = el('input', { id: id, type: 'text', readonly: '', spellcheck: 'false', 'aria-label': label });
+ field.value = value;
+ return el('div', { className: 'ovp-copy-row' }, [field, el('button', { className: 'ovp-btn', type: 'button', 'data-copy': id, text: 'Copy' })]);
+ }
+
+ function row(file) {
+ var bar = el('span');
+ var main = el('div', { className: 'ovp-result-main' }, [el('div', { className: 'ovp-result-name', text: file.name || 'Pasted picture' }), el('div', { className: 'ovp-progress', role: 'progressbar', 'aria-label': 'Uploading' }, [bar])]);
+ var img = el('img', { alt: '' });
+ try { img.src = URL.createObjectURL(file); } catch (e) { img = el('span', { className: 'ovp-result-ph' }); }
+ var li = el('li', { className: 'ovp-result' }, [img, main]);
+ results.prepend(li);
+ return { li: li, main: main, bar: bar };
+ }
+
+ function fail(r, text) {
+ var p = r.main.querySelector('.ovp-progress');
+ if (p) p.remove();
+ r.main.appendChild(el('div', { className: 'ovp-result-error', text: text }));
+ }
+
+ function send(file, r) {
+ return new Promise(function (resolve) {
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', '/api/v1/images');
+ xhr.setRequestHeader('Content-Type', file.type);
+ xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name || 'pasted'));
+ xhr.upload.onprogress = function (e) { if (e.lengthComputable) r.bar.style.width = Math.round(e.loaded / e.total * 100) + '%'; };
+ xhr.onload = function () {
+ var body = null;
+ try { body = JSON.parse(xhr.responseText); } catch (e) { body = null; }
+ if (xhr.status === 201 && body && body.links) {
+ var p = r.main.querySelector('.ovp-progress');
+ if (p) p.remove();
+ var name = r.main.querySelector('.ovp-result-name');
+ name.textContent = '';
+ name.appendChild(el('a', { href: body.links.page, text: body.title || 'Your picture' }));
+ r.main.appendChild(copyRow('Link to the picture’s page', body.links.page));
+ r.main.appendChild(copyRow('Direct link', body.links.direct));
+ if (body.delete_url) r.main.appendChild(el('div', { className: 'ovp-result-error', text: 'Keep this delete link, shown only now: ' + body.delete_url }));
+ } else if (xhr.status === 401) {
+ fail(r, 'Sign in with OpenVibe to upload.');
+ } else {
+ fail(r, (body && body.detail) || 'That picture could not be uploaded. Try again.');
+ }
+ resolve();
+ };
+ xhr.onerror = function () { fail(r, 'The connection dropped. Try again.'); resolve(); };
+ xhr.send(file);
+ });
+ }
+
+ function pump() {
+ if (busy || !queue.length) return;
+ busy = true;
+ var job = queue.shift();
+ send(job.file, job.r).then(function () { busy = false; pump(); });
+ }
+
+ function add(files) {
+ Array.prototype.forEach.call(files, function (file) {
+ var r = row(file);
+ if (TYPES.indexOf(file.type) === -1) return fail(r, 'Pictures can be JPEG, PNG, GIF, WebP or AVIF.');
+ if (max && file.size > max) return fail(r, 'This picture is larger than ' + Math.round(max / 1048576) + ' MB.');
+ queue.push({ file: file, r: r });
+ return undefined;
+ });
+ pump();
+ }
+
+ input.addEventListener('change', function () { if (input.files && input.files.length) add(input.files); input.value = ''; });
+ form.addEventListener('submit', function (ev) { ev.preventDefault(); if (input.files && input.files.length) add(input.files); });
+
+ var depth = 0;
+ document.addEventListener('dragenter', function (e) { if (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types || [], 'Files') !== -1) { depth++; form.classList.add('is-dragover'); } });
+ document.addEventListener('dragleave', function () { depth = Math.max(0, depth - 1); if (!depth) form.classList.remove('is-dragover'); });
+ document.addEventListener('dragover', function (e) { if (form.classList.contains('is-dragover')) e.preventDefault(); });
+ document.addEventListener('drop', function (e) {
+ if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
+ e.preventDefault();
+ depth = 0;
+ form.classList.remove('is-dragover');
+ add(e.dataTransfer.files);
+ });
+ document.addEventListener('paste', function (e) {
+ var t = e.target;
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA') && t.type !== 'file') return;
+ var items = (e.clipboardData && e.clipboardData.files) || [];
+ if (items.length) { e.preventDefault(); add(items); }
+ });
+})();
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
/**
/**
* This service's share of a person's account export and deletion (ADR-033), through openvibe-sdk/account-data.
* This service's share of a person's account export and deletion (ADR-033), through openvibe-sdk/account-data.
*
*
- * network.account.export_requested the person's files, folders, shares, reports and upload counts, as JSON
+ * network.account.export_requested the person's files, folders, shares, reports and upload counts, playlists, and
+ * their OpenVibe.Pics pictures, albums, reports and upload counts, as JSON
* network.account.deleted the same rows erased, and every Media object they held deleted
* network.account.deleted the same rows erased, and every Media object they held deleted
*
*
* The events arrive signed on POST /internal/events (loopback only). `apply` is wrapped here for one reason: an
* The events arrive signed on POST /internal/events (loopback only). `apply` is wrapped here for one reason: an
@@ -35,6 +36,13 @@ const TABLES = [
@@ -35,6 +36,13 @@ const TABLES = [
// An upload that never finished is this service's own state, not something the person made: it is erased with
// An upload that never finished is this service's own state, not something the person made: it is erased with
// the account but is not part of the export.
// the account but is not part of the export.
{ table: 'mh_uploads', subject: 'owner', value: AS_REQUESTER, file: null },
{ table: 'mh_uploads', subject: 'owner', value: AS_REQUESTER, file: null },
+ // OpenVibe.Pics (migrations/0006_pics.sql). The reports a person filed go first; the reports other people filed
+ // on their pictures, and the album places, cascade with the pictures and albums (exported with extraExport).
+ // pics_blocklist is staff's record of what was taken down, not the person's data, so it stays.
+ { table: 'pics_reports', subject: 'reporter', value: AS_REQUESTER, file: 'pics_reports.json' },
+ { table: 'pics_albums', subject: 'owner', value: AS_REQUESTER, file: 'pics_albums.json' },
+ { table: 'pics_images', subject: 'owner', value: AS_REQUESTER, file: 'pics_images.json' },
+ { table: 'pics_usage', subject: 'uploader', value: AS_REQUESTER, file: 'pics_usage.json' },
];
];
function createAccount({ db, config, media, log = console }) {
function createAccount({ db, config, media, log = console }) {
@@ -45,7 +53,7 @@ function createAccount({ db, config, media, log = console }) {
@@ -45,7 +53,7 @@ function createAccount({ db, config, media, log = console }) {
db,
db,
service: 'media-hub',
service: 'media-hub',
tables: TABLES,
tables: TABLES,
- note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts and your playlists. The bytes of your files live in OpenVibe.Media.',
+ note: 'OpenVibe.MediaHub: your files, folders, share links, the reports you filed, your upload counts and your playlists, and your OpenVibe.Pics pictures and albums. The bytes of your files and pictures live in OpenVibe.Media.',
extraExport: async (db, subject) => {
extraExport: async (db, subject) => {
const rows = await db.many(
const rows = await db.many(
`SELECT i.playlist_id, i.kind, i.item_id, i.position, i.added_at
`SELECT i.playlist_id, i.kind, i.item_id, i.position, i.added_at
@@ -53,7 +61,17 @@ function createAccount({ db, config, media, log = console }) {
@@ -53,7 +61,17 @@ function createAccount({ db, config, media, log = console }) {
JOIN video_playlists p ON p.id = i.playlist_id
JOIN video_playlists p ON p.id = i.playlist_id
WHERE p.owner = $1
WHERE p.owner = $1
ORDER BY i.playlist_id, i.position ASC`, [`user:${subject}`]);
ORDER BY i.playlist_id, i.position ASC`, [`user:${subject}`]);
- return rows.length ? [{ name: 'playlist_items.json', content: rows }] : [];
+ const albumRows = await db.many(
+ `SELECT ai.album_id, a.slug AS album_slug, i.slug AS image_slug, ai.position, ai.added_at
+ FROM pics_album_images ai
+ JOIN pics_albums a ON a.id = ai.album_id
+ JOIN pics_images i ON i.id = ai.image_id
+ WHERE a.owner = $1
+ ORDER BY ai.album_id, ai.position ASC`, [`user:${subject}`]);
+ return [
+ ...(rows.length ? [{ name: 'playlist_items.json', content: rows }] : []),
+ ...(albumRows.length ? [{ name: 'pics_album_images.json', content: albumRows }] : []),
+ ];
},
},
extraErase: async () => {
extraErase: async () => {
const ids = pendingObjects;
const ids = pendingObjects;
@@ -78,8 +96,11 @@ function createAccount({ db, config, media, log = console }) {
@@ -78,8 +96,11 @@ function createAccount({ db, config, media, log = console }) {
if (ev && ev.event_type === 'network.account.deleted' && ev.source === 'network') {
if (ev && ev.event_type === 'network.account.deleted' && ev.source === 'network') {
const payload = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
const payload = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
// Read the media objects first: the erase deletes the rows that name them.
// Read the media objects first: the erase deletes the rows that name them.
- pendingObjects = (await db.many('SELECT object_id FROM mh_files WHERE owner = ANY($1::text[])', [subjectsOf(payload)]))
- .map((r) => r.object_id).filter(Boolean);
+ const subjects = subjectsOf(payload);
+ pendingObjects = [
+ ...(await db.many('SELECT object_id FROM mh_files WHERE owner = ANY($1::text[])', [subjects])).map((r) => r.object_id),
+ ...(await db.many('SELECT object_id, thumb_object_id FROM pics_images WHERE owner = ANY($1::text[])', [subjects])).flatMap((r) => [r.object_id, r.thumb_object_id]),
+ ].filter(Boolean);
}
}
return await accountData.apply(ev, { send });
return await accountData.apply(ev, { send });
}
}
@@ -29,6 +29,8 @@ const { createPrincipal } = require('./http/principal');
@@ -29,6 +29,8 @@ const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createVideoRoutes } = require('./video/pages');
const { createVideoRoutes } = require('./video/pages');
+const { createPicsRoutes } = require('./pics/pages');
+const { createPics } = require('./pics/service');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
@@ -67,7 +69,8 @@ async function createApp(opts = {}) {
@@ -67,7 +69,8 @@ async function createApp(opts = {}) {
const uploads = createUploads({ s, media, config, service: drive, log });
const uploads = createUploads({ s, media, config, service: drive, log });
const account = createAccount({ db: s.db, config, media, log });
const account = createAccount({ db: s.db, config, media, log });
const playlists = createPlaylists({ s, config, log });
const playlists = createPlaylists({ s, config, log });
- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, fetchImpl, log };
+ const pics = createPics({ s, media, config, log });
+ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, fetchImpl, log };
// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.
// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.
ctx.indexnow = opts.indexnow || (() => {
ctx.indexnow = opts.indexnow || (() => {
const warn = (...a) => (log.warn || console.warn)(...a);
const warn = (...a) => (log.warn || console.warn)(...a);
@@ -142,14 +145,17 @@ async function createApp(opts = {}) {
@@ -142,14 +145,17 @@ async function createApp(opts = {}) {
// origin's /release.json, and so does the host's browser watch.
// origin's /release.json, and so does the host's browser watch.
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
- // ── Host routing: openvibe.video is OpenVibe.Video; every other host (openvibe.download) stays
- // the drive. On the video host the drive's pages, API and legal pages never answer.
+ // ── Host routing: openvibe.video is OpenVibe.Video, openvibe.pics is OpenVibe.Pics; every other host
+ // (openvibe.download) stays the drive. On those hosts the drive's pages, API and legal pages never answer.
const videoHost = (() => { try { return new URL(config.video.url).hostname; } catch { return null; } })();
const videoHost = (() => { try { return new URL(config.video.url).hostname; } catch { return null; } })();
+ const picsHost = (() => { try { return new URL(config.pics.url).hostname; } catch { return null; } })();
const videoRoutes = createVideoRoutes(ctx);
const videoRoutes = createVideoRoutes(ctx);
+ const picsRoutes = createPicsRoutes(ctx);
// GET /<key>.txt on the video host: IndexNow's key file, only when a key is configured.
// GET /<key>.txt on the video host: IndexNow's key file, only when a key is configured.
if (ctx.indexnow.enabled) app.use((req, res, next) => (videoHost && req.hostname === videoHost ? ctx.indexnow.keyFile(req, res, next) : next()));
if (ctx.indexnow.enabled) app.use((req, res, next) => (videoHost && req.hostname === videoHost ? ctx.indexnow.keyFile(req, res, next) : next()));
app.use((req, res, next) => {
app.use((req, res, next) => {
if (videoHost && req.hostname === videoHost) return videoRoutes(req, res, next);
if (videoHost && req.hostname === videoHost) return videoRoutes(req, res, next);
+ if (picsHost && req.hostname === picsHost) return picsRoutes(req, res, next);
return next();
return next();
});
});
@@ -59,9 +59,14 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -59,9 +59,14 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
// host-only (no Domain), so each host keeps its own session.
// host-only (no Domain), so each host keeps its own session.
const videoRedirectUri = (() => { try { return `${String(config.video.url).replace(/\/+$/, '')}/auth/callback`; } catch { return null; } })();
const videoRedirectUri = (() => { try { return `${String(config.video.url).replace(/\/+$/, '')}/auth/callback`; } catch { return null; } })();
const videoHostname = (() => { try { return new URL(config.video.url).hostname; } catch { return null; } })();
const videoHostname = (() => { try { return new URL(config.video.url).hostname; } catch { return null; } })();
- const redirectUriFor = (req) => (videoRedirectUri && videoHostname && req && req.hostname === videoHostname
- ? videoRedirectUri
- : config.oauth.redirectUri);
+ // openvibe.pics likewise (https://openvibe.pics/auth/callback, registered for client media-hub on 2026-10-15).
+ const picsRedirectUri = (() => { try { return `${String(config.pics.url).replace(/\/+$/, '')}/auth/callback`; } catch { return null; } })();
+ const picsHostname = (() => { try { return new URL(config.pics.url).hostname; } catch { return null; } })();
+ const redirectUriFor = (req) => {
+ if (videoRedirectUri && videoHostname && req && req.hostname === videoHostname) return videoRedirectUri;
+ if (picsRedirectUri && picsHostname && req && req.hostname === picsHostname) return picsRedirectUri;
+ return config.oauth.redirectUri;
+ };
function setSession(res, data) {
function setSession(res, data) {
res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
@@ -71,6 +71,27 @@ function load(env = process.env) {
@@ -71,6 +71,27 @@ function load(env = process.env) {
indexnowKey: String(env.INDEXNOW_KEY || '').trim(),
indexnowKey: String(env.INDEXNOW_KEY || '').trim(),
},
},
+ // OpenVibe.Pics (server/pics): picture hosting on openvibe.pics, chosen by Host header like the video host.
+ // The bytes are private Media objects, re-encoded here with their metadata stripped and served only through
+ // this service (/i/<slug>.<ext>). Signed-out uploads stay off until the DMCA agent and the CSAM
+ // hash-matching account exist (plan T10 gate, owner items O25 and the CSAM tool account): `anonymous` is
+ // MEDIAHUB_PICS_ANONYMOUS=1 then.
+ pics: {
+ url: trim(env.MEDIAHUB_PICS_URL || 'https://openvibe.pics'),
+ maxImageBytes: size(env.MEDIAHUB_PICS_MAX_IMAGE_BYTES, 20 * MB),
+ maxPixels: size(env.MEDIAHUB_PICS_MAX_PIXELS, 100_000_000),
+ quotaBytes: size(env.MEDIAHUB_PICS_QUOTA_BYTES, 5 * GB),
+ imagesPerDay: size(env.MEDIAHUB_PICS_IMAGES_PER_DAY, 200),
+ albumsMax: size(env.MEDIAHUB_PICS_ALBUMS_MAX, 200),
+ albumImagesMax: size(env.MEDIAHUB_PICS_ALBUM_IMAGES_MAX, 500),
+ reportsToHide: Math.max(1, size(env.MEDIAHUB_PICS_REPORTS_TO_HIDE, 3)),
+ anonymous: env.MEDIAHUB_PICS_ANONYMOUS === '1',
+ anonymousMaxImageBytes: size(env.MEDIAHUB_PICS_ANONYMOUS_MAX_IMAGE_BYTES, 10 * MB),
+ anonymousPerDay: size(env.MEDIAHUB_PICS_ANONYMOUS_PER_DAY, 20),
+ // A signed-out upload is deleted after this many days without a view.
+ anonymousIdleDays: Math.max(1, size(env.MEDIAHUB_PICS_ANONYMOUS_IDLE_DAYS, 30)),
+ },
+
// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one
// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one
// tenant), so the per-person numbers are enforced in MediaHub and nowhere else.
// tenant), so the per-person numbers are enforced in MediaHub and nowhere else.
quotas: {
quotas: {
@@ -47,6 +47,11 @@ const BUDGETS = {
@@ -47,6 +47,11 @@ const BUDGETS = {
'media-hub.share.create': { minute: 20, hour: 200 },
'media-hub.share.create': { minute: 20, hour: 200 },
'media-hub.playlist.create': { minute: 30, hour: 300 },
'media-hub.playlist.create': { minute: 30, hour: 300 },
'media-hub.playlist.write': { minute: 120, hour: 1_200 },
'media-hub.playlist.write': { minute: 120, hour: 1_200 },
+ // OpenVibe.Pics (server/pics): a burst of pictures dropped at once, but not a script emptying a disk into it.
+ 'pics.image.upload': { minute: 30, hour: 300 },
+ 'pics.image.write': { minute: 60, hour: 600 },
+ 'pics.album.create': { minute: 10, hour: 100 },
+ 'pics.report.create': { minute: 5, hour: 30 },
};
};
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
@@ -43,7 +43,15 @@ async function start() {
@@ -43,7 +43,15 @@ async function start() {
const { createLiveClient } = require('./video/live-client');
const { createLiveClient } = require('./video/live-client');
const videoIndexNow = createVideoIndexNow({ config, live: createLiveClient({ config, fetchImpl: ctx.fetchImpl, log: ctx.log }), indexnow: ctx.indexnow, log: ctx.log });
const videoIndexNow = createVideoIndexNow({ config, live: createLiveClient({ config, fetchImpl: ctx.fetchImpl, log: ctx.log }), indexnow: ctx.indexnow, log: ctx.log });
videoIndexNow.start();
videoIndexNow.start();
- createLifecycle({ server, ctx, extra: [() => videoIndexNow.stop(), () => { if (subscriptions) subscriptions.stop(); }] });
+ // OpenVibe.Pics: signed-out pictures nobody has viewed for MEDIAHUB_PICS_ANONYMOUS_IDLE_DAYS go, hourly (only while
+ // signed-out uploads are on; account pictures stay until their owner deletes them).
+ const timers = [];
+ if (config.pics.anonymous) {
+ const sweep = setInterval(() => { ctx.pics.sweep().then((n) => { if (n) console.log(`[OpenVibe.Pics] deleted ${n} idle signed-out picture(s)`); }).catch((err) => console.warn('[OpenVibe.Pics] sweep failed:', err && err.message)); }, 3_600_000);
+ sweep.unref();
+ timers.push(sweep);
+ }
+ createLifecycle({ server, ctx, timers, extra: [() => videoIndexNow.stop(), () => { if (subscriptions) subscriptions.stop(); }] });
return { server, ctx };
return { server, ctx };
}
}
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* Network service token (audience `openvibe.media`), and never hands that token, an upload token or a signed URL
* Network service token (audience `openvibe.media`), and never hands that token, an upload token or a signed URL
* to a browser.
* to a browser.
*
*
- * init({ size, contentType, name, ownerSubject, multipart }) POST /api/v2/media-hub/objects
+ * init({ size, contentType, name, ownerSubject, multipart, visibility, ownerless }) POST /api/v2/media-hub/objects
* putContent(objectId, buffer) PUT …/objects/:id/content
* putContent(objectId, buffer) PUT …/objects/:id/content
* complete(objectId) POST …/objects/:id/complete
* complete(objectId) POST …/objects/:id/complete
* multipartStatus(objectId, uploadId) GET …/objects/:id/multipart/:uploadId
* multipartStatus(objectId, uploadId) GET …/objects/:id/multipart/:uploadId
@@ -84,14 +84,16 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -84,14 +84,16 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
enabled,
enabled,
/** Start an object. Media gives it back `uploading` until its bytes arrive and it is completed. */
/** Start an object. Media gives it back `uploading` until its bytes arrive and it is completed. */
- async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null }) {
+ async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null, visibility = 'private', ownerless = false }) {
+ // A drive file always belongs to a person. A picture uploaded signed out (Pics, `ownerless`) is the
+ // service's own object, with no subject on it.
const subject = subjectHeader(ownerSubject);
const subject = subjectHeader(ownerSubject);
- if (!subject) return { ok: false, status: 400, code: 'file.bad_owner', detail: 'a file belongs to a person with a canonical subject' };
+ if (!subject && !ownerless) return { ok: false, status: 400, code: 'file.bad_owner', detail: 'a file belongs to a person with a canonical subject' };
return await call('', {
return await call('', {
method: 'POST',
method: 'POST',
- headers: { 'X-OV-Subject': subject },
+ headers: subject ? { 'X-OV-Subject': subject } : {},
json: {
json: {
- kind: 'file', visibility: 'private', size_bytes: size,
+ kind: 'file', visibility: ['private', 'unlisted', 'public'].includes(visibility) ? visibility : 'private', size_bytes: size,
// Media only needs a label: the real name (any script) is MediaHub's, and Media's own download
// Media only needs a label: the real name (any script) is MediaHub's, and Media's own download
// header takes a filename as is, so it gets the printable-ASCII rendering.
// header takes a filename as is, so it gets the printable-ASCII rendering.
mime_type: contentType || null, filename: headerSafeName(name), multipart: multipart === true,
mime_type: contentType || null, filename: headerSafeName(name), multipart: multipart === true,
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -0,0 +1,183 @@
+'use strict';
+
+/**
+ * /api/v1 on openvibe.pics — OpenVibe.Pics' API. A person is a Network token as a Bearer, or this site's session (a
+ * session write must come from openvibe.pics itself). App, agent and service tokens are refused: a picture is
+ * somebody's own. Signed-out uploads answer only when MEDIAHUB_PICS_ANONYMOUS=1.
+ *
+ * POST /images person upload: the picture is the body (Content-Type image/*);
+ * X-Title and X-Filename headers (URI-encoded) are optional
+ * GET /images/:slug anyone a picture's metadata and links (hidden: uploader and staff only)
+ * PATCH /images/:slug uploader { title, description }
+ * DELETE /images/:slug uploader (signed out: the X-Delete-Key header from the upload)
+ * POST /images/:slug/reports anyone { reason, note }
+ * GET /me/images?before= person your pictures, newest first
+ * GET /albums person your albums
+ * POST /albums person { title, description }
+ * GET /albums/:slug anyone an album and its pictures
+ * POST /albums/:slug/images owner { image: slug }
+ * DELETE /albums/:slug/images/:image owner
+ * DELETE /albums/:slug owner (the pictures stay)
+ *
+ * Refusals are RFC 9457 problem+json with a stable code (pics.*).
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { asyncRouter } = require('../http/router');
+const { sameOrigin } = require('../http/principal');
+const store = require('./store');
+
+function createPicsApi(ctx) {
+ const { config, s, principal, limits } = ctx;
+ const pics = ctx.pics;
+ const cfg = config.pics;
+ const r = asyncRouter();
+ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
+
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ r.use(principal.middleware);
+
+ const whoOf = (req) => (req.principal.kind === 'user'
+ ? { requester: req.principal.requester, staff: pics.isStaff({ kind: 'user', role: req.principal.role || (req.principal.claims && req.principal.claims.role) }), ip: req.ip }
+ : { ip: req.ip });
+
+ /** A person may act; signed out only where `anonymousOk`; app tokens never. Session writes come from this site. */
+ function gate({ anonymousOk = false } = {}) {
+ return (req, res, next) => {
+ const p = req.principal;
+ if (p.kind !== 'user' && p.kind !== 'anonymous') return problem(req, res, 403, 'pics.person_required', 'Pictures belong to a person; an app, agent or service token cannot upload or change them.');
+ if (p.kind === 'anonymous' && !anonymousOk) return problem(req, res, 401, 'token.required', 'Sign in at openvibe.pics, or send a person\'s Network token as a Bearer.');
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, cfg.url)) return problem(req, res, 403, 'request.cross_site', 'A signed-in request that changes something must come from openvibe.pics itself.');
+ return next();
+ };
+ }
+ const person = gate();
+ const anyone = gate({ anonymousOk: true });
+ const header = (req, name, max) => { try { return decodeURIComponent(String(req.get(name) || '')).slice(0, max); } catch { return ''; } };
+
+ async function load(req, res) {
+ const slug = String(req.params.slug || '');
+ const row = pics.isSlug(slug) ? await store.imageBySlug(s, slug) : null;
+ if (!row) { problem(req, res, 404, 'pics.not_found', 'No such picture.'); return null; }
+ if (row.state === 'removed') { problem(req, res, 410, 'pics.taken_down', 'This picture was taken down.'); return null; }
+ if (!pics.canSee(row, whoOf(req))) { problem(req, res, 404, 'pics.not_found', 'No such picture.'); return null; }
+ return row;
+ }
+ async function loadOwn(req, res) {
+ const row = await load(req, res);
+ if (!row) return null;
+ if (!pics.isOwner(row, whoOf(req))) { problem(req, res, 403, 'pics.not_yours', 'Only the person who uploaded this picture can change it.'); return null; }
+ return row;
+ }
+
+ // ── Upload: the raw picture is the body ─────────────────
+ const rawBody = express.raw({ type: () => true, limit: Math.max(cfg.maxImageBytes, cfg.anonymousMaxImageBytes) + 1 });
+ r.post('/images', anyone, limits.budget('pics.image.upload'), (req, res, next) => rawBody(req, res, (err) => {
+ if (err && err.type === 'entity.too.large') return problem(req, res, 413, 'pics.too_large', `A picture can be up to ${Math.round(cfg.maxImageBytes / 1024 / 1024)} MB.`);
+ return next(err);
+ }), async (req, res) => {
+ if (req.principal.kind === 'anonymous' && !cfg.anonymous) return problem(req, res, 401, 'pics.sign_in_required', 'Sign in with OpenVibe to upload pictures.');
+ if (!/^image\//i.test(String(req.get('content-type') || ''))) return problem(req, res, 415, 'pics.not_an_image', 'Send the picture as the body, with its image/* Content-Type.');
+ const out = await pics.upload(whoOf(req), { buffer: Buffer.isBuffer(req.body) ? req.body : null, name: header(req, 'x-filename', 200) }, { title: header(req, 'x-title', 200) });
+ if (!out.ok) return problem(req, res, out.status, out.code, out.detail);
+ res.set('Location', `/api/v1/images/${out.image.slug}`);
+ return res.status(201).json({ ...pics.toWire(out.image, { owner: true }), ...(out.deleteKey ? { delete_key: out.deleteKey, delete_url: `${cfg.url}/i/${out.image.slug}/delete?key=${out.deleteKey}` } : {}) });
+ });
+
+ r.use(express.json({ limit: '64kb' }));
+
+ r.get('/images/:slug', async (req, res) => {
+ const row = await load(req, res);
+ if (!row) return undefined;
+ return res.json(pics.toWire(row, { owner: pics.isOwner(row, whoOf(req)) }));
+ });
+
+ r.patch('/images/:slug', person, limits.budget('pics.image.write'), async (req, res) => {
+ const row = await loadOwn(req, res);
+ if (!row) return undefined;
+ const b = req.body || {};
+ return res.json(pics.toWire(await pics.edit(row, { title: b.title, description: b.description }), { owner: true }));
+ });
+
+ r.delete('/images/:slug', anyone, limits.budget('pics.image.write'), async (req, res) => {
+ const row = await load(req, res);
+ if (!row) return undefined;
+ if (!(pics.isOwner(row, whoOf(req)) || pics.deleteKeyMatches(row, String(req.get('x-delete-key') || '')))) return problem(req, res, 403, 'pics.not_yours', 'Only the person who uploaded this picture can delete it.');
+ await pics.remove(row);
+ return res.status(204).end();
+ });
+
+ r.post('/images/:slug/reports', anyone, limits.budget('pics.report.create'), async (req, res) => {
+ const row = await load(req, res);
+ if (!row) return undefined;
+ const who = whoOf(req);
+ const b = req.body || {};
+ const out = await pics.report(row, { reporter: who.requester || pics.addressId(who.ip), reason: String(b.reason || ''), note: b.note });
+ if (!out.ok) return problem(req, res, out.status, out.code, out.detail);
+ return res.status(out.added ? 201 : 200).json({ reported: true, already: !out.added, hidden: out.hidden });
+ });
+
+ r.get('/me/images', person, async (req, res) => {
+ const before = /^pic_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.query.before || '')) ? String(req.query.before) : null;
+ const limit = Math.min(100, Math.max(1, Number.parseInt(req.query.limit, 10) || 48));
+ const out = await store.imagesOf(s, whoOf(req).requester, { limit, before });
+ return res.json({ images: out.rows.map((x) => pics.toWire(x, { owner: true })), next: out.next, usage: await store.usageOf(s, whoOf(req).requester), quota_bytes: cfg.quotaBytes });
+ });
+
+ // ── Albums ──────────────────────────────────────────────
+ const albumWire = (a, images) => ({ id: a.id, slug: a.slug, title: a.title, description: a.description, url: `${cfg.url}/a/${a.slug}`, created_at: a.created_at, updated_at: a.updated_at, ...(images ? { images } : { image_count: Number(a.image_count || 0) }) });
+ async function ownAlbum(req, res) {
+ const slug = String(req.params.slug || '');
+ const a = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;
+ if (!a || a.owner !== whoOf(req).requester) { problem(req, res, 404, 'pics.album_not_found', 'No such album of yours.'); return null; }
+ return a;
+ }
+
+ r.get('/albums', person, async (req, res) => res.json({ albums: (await store.albumsOf(s, whoOf(req).requester)).map((a) => albumWire(a)) }));
+
+ r.post('/albums', person, limits.budget('pics.album.create'), async (req, res) => {
+ const out = await pics.createAlbum(whoOf(req).requester, req.body || {});
+ if (!out.ok) return problem(req, res, out.status, out.code, out.detail);
+ res.set('Location', `/api/v1/albums/${out.album.slug}`);
+ return res.status(201).json(albumWire(out.album, []));
+ });
+
+ r.get('/albums/:slug', async (req, res) => {
+ const slug = String(req.params.slug || '');
+ const a = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;
+ if (!a) return problem(req, res, 404, 'pics.album_not_found', 'No such album.');
+ const owner = a.owner === whoOf(req).requester;
+ return res.json(albumWire(a, (await store.albumImages(s, a.id, { visibleOnly: !owner })).map((x) => pics.toWire(x, { owner }))));
+ });
+
+ r.post('/albums/:slug/images', person, limits.budget('pics.image.write'), async (req, res) => {
+ const a = await ownAlbum(req, res);
+ if (!a) return undefined;
+ const imgSlug = String((req.body || {}).image || '');
+ const img = pics.isSlug(imgSlug) ? await store.imageBySlug(s, imgSlug) : null;
+ if (!img || !pics.isOwner(img, whoOf(req)) || img.state === 'removed') return problem(req, res, 422, 'pics.not_yours', 'image: the slug of one of your pictures');
+ const out = await pics.addToAlbum(a, img);
+ if (!out.ok) return problem(req, res, out.status, out.code, out.detail);
+ return res.status(204).end();
+ });
+
+ r.delete('/albums/:slug/images/:image', person, limits.budget('pics.image.write'), async (req, res) => {
+ const a = await ownAlbum(req, res);
+ if (!a) return undefined;
+ const img = pics.isSlug(req.params.image) ? await store.imageBySlug(s, String(req.params.image)) : null;
+ if (img) await store.removeFromAlbum(s, a.id, img.id);
+ return res.status(204).end();
+ });
+
+ r.delete('/albums/:slug', person, limits.budget('pics.image.write'), async (req, res) => {
+ const a = await ownAlbum(req, res);
+ if (!a) return undefined;
+ await store.deleteAlbum(s, a.id);
+ return res.status(204).end();
+ });
+
+ r.use((req, res) => problem(req, res, 404, 'route.not_found', 'No such API route on openvibe.pics.'));
+ return r;
+}
+
+module.exports = { createPicsApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+
+/**
+ * What happens to a picture before anyone sees it (sharp, libvips):
+ *
+ * - It must decode as JPEG, PNG, GIF, WebP or AVIF, within MEDIAHUB_PICS_MAX_PIXELS. Anything else — a script
+ * renamed .png, an SVG, a truncated or malformed file — is refused. Nothing is served as it was uploaded.
+ * - It is re-encoded in its own format: the orientation is applied, and every metadata block (EXIF with its GPS
+ * position and camera serial, XMP, IPTC, comments) is dropped. An animated GIF or WebP keeps its frames.
+ * A colour profile is converted to sRGB rather than kept.
+ * - A 640-pixel WebP thumbnail is made (the first frame of an animation).
+ * - Three hashes: sha256 of what was uploaded and of what is served, and a 64-bit difference hash (dHash) of a
+ * 9×8 greyscale copy, which stays close for a resized, re-compressed or lightly edited copy of the same picture.
+ * The takedown blocklist matches on all three (server/pics/service.js).
+ *
+ * prepare(buffer) → { ok: true, image: { buffer, mime, ext, width, height, animated }, thumb: { buffer, mime,
+ * width, height }, sha256, sourceSha256, dhash } or { ok: false, code, detail }.
+ */
+const crypto = require('crypto');
+const sharp = require('sharp');
+
+// libvips caches decoded images; this process serves pages too, so keep its memory small and its threads few.
+sharp.cache({ memory: 64, files: 0, items: 50 });
+sharp.concurrency(2);
+
+const FORMATS = {
+ jpeg: { mime: 'image/jpeg', ext: 'jpg' },
+ png: { mime: 'image/png', ext: 'png' },
+ gif: { mime: 'image/gif', ext: 'gif' },
+ webp: { mime: 'image/webp', ext: 'webp' },
+ heif: { mime: 'image/avif', ext: 'avif' }, // sharp reports AVIF as heif; only the AV1 kind decodes
+};
+const EXTS = Object.values(FORMATS).map((f) => f.ext);
+const THUMB = 640;
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+
+/** The 64-bit difference hash of a picture's first frame, as 16 hex digits. */
+async function dhashOf(input) {
+ const px = await sharp(input, { pages: 1, failOn: 'error' }).rotate().grayscale().resize(9, 8, { fit: 'fill', kernel: 'cubic' }).raw().toBuffer();
+ let bits = 0n;
+ for (let y = 0; y < 8; y++) {
+ // A margin of 2 levels: in a flat area two neighbours differ by compression noise only, and a bare `>` would
+ // flip with every re-encode.
+ for (let x = 0; x < 8; x++) bits = (bits << 1n) | (px[y * 9 + x] - px[y * 9 + x + 1] > 2 ? 1n : 0n);
+ }
+ return bits.toString(16).padStart(16, '0');
+}
+
+/** How many of the 64 bits two difference hashes disagree on. */
+function distance(a, b) {
+ let x = BigInt(`0x${a}`) ^ BigInt(`0x${b}`);
+ let n = 0;
+ while (x) { n += Number(x & 1n); x >>= 1n; }
+ return n;
+}
+
+async function prepare(input, { maxPixels = 100_000_000 } = {}) {
+ if (!Buffer.isBuffer(input) || input.length < 16) return { ok: false, code: 'pics.not_an_image', detail: 'That file is not a picture we can show.' };
+ let meta;
+ try {
+ meta = await sharp(input, { limitInputPixels: maxPixels, failOn: 'error', animated: true }).metadata();
+ } catch (err) {
+ const tooBig = /pixel limit/i.test(String(err && err.message));
+ return tooBig
+ ? { ok: false, code: 'pics.too_many_pixels', detail: `That picture has more than ${Number((maxPixels / 1e6).toPrecision(3))} megapixels.` }
+ : { ok: false, code: 'pics.not_an_image', detail: 'That file is not a JPEG, PNG, GIF, WebP or AVIF picture we can read.' };
+ }
+ const format = FORMATS[meta.format];
+ if (!format || (meta.format === 'heif' && meta.compression !== 'av1')) {
+ return { ok: false, code: 'pics.unsupported', detail: 'Pictures can be JPEG, PNG, GIF, WebP or AVIF.' };
+ }
+ const pages = Number(meta.pages) || 1;
+ const animated = pages > 1 && (meta.format === 'gif' || meta.format === 'webp');
+ try {
+ // .rotate() with no angle applies the EXIF orientation; libvips drops all metadata on output unless asked.
+ let pipe = sharp(input, { limitInputPixels: maxPixels, failOn: 'error', animated });
+ if (!animated) pipe = pipe.rotate();
+ pipe = pipe.toColourspace('srgb');
+ if (meta.format === 'jpeg') pipe = pipe.jpeg({ quality: 88, mozjpeg: true, chromaSubsampling: '4:2:0' });
+ else if (meta.format === 'png') pipe = pipe.png({ compressionLevel: 9, adaptiveFiltering: true });
+ else if (meta.format === 'gif') pipe = pipe.gif({ effort: 7 });
+ else if (meta.format === 'webp') pipe = pipe.webp({ quality: 90, effort: 4 });
+ else pipe = pipe.avif({ quality: 62, effort: 4 });
+ const { data, info } = await pipe.toBuffer({ resolveWithObject: true });
+ const height = animated ? Math.round(info.height / (info.pages || pages)) : info.height;
+
+ const thumb = await sharp(data, { pages: 1, failOn: 'error' })
+ .resize(THUMB, THUMB, { fit: 'inside', withoutEnlargement: true })
+ .webp({ quality: 76, effort: 4 })
+ .toBuffer({ resolveWithObject: true });
+
+ return {
+ ok: true,
+ image: { buffer: data, mime: format.mime, ext: format.ext, width: info.width, height, animated },
+ thumb: { buffer: thumb.data, mime: 'image/webp', width: thumb.info.width, height: thumb.info.height },
+ sha256: sha256(data),
+ sourceSha256: sha256(input),
+ dhash: await dhashOf(data),
+ };
+ } catch {
+ return { ok: false, code: 'pics.not_an_image', detail: 'That picture could not be read to the end. Is the file complete?' };
+ }
+}
+
+module.exports = { prepare, dhashOf, distance, sha256, EXTS, FORMATS, THUMB };
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+
+/**
+ * The OpenVibe.Pics page shell. Same composition as the video host's (openvibe-shared/shell, the OpenVibe Frame
+ * navbar and footer, the shared app icon); every canonical URL is on the pics host, and the brand is OpenVibe.Pics.
+ *
+ * A page rendered for a signed-in person names them (and a session cookie is present), so it is
+ * private and never cached; every other page stays public. Cloudflare ignores Vary, so the cache
+ * decision is made from the session cookie's presence rather than Vary.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const appIcon = require('openvibe-shared/app-icon');
+const shell = require('openvibe-shared/shell');
+const cache = require('openvibe-shared/cache-policy');
+const { html, raw, esc, render } = require('../render/html');
+
+const NETWORK_URL = 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Pics';
+const TAGLINE = 'Share pictures. Fast, open, yours.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+
+const hashes = new Map();
+function assetVersion(rel) {
+ if (hashes.has(rel)) return hashes.get(rel);
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
+ hashes.set(rel, v);
+ return v;
+}
+const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
+
+/** o: title, description, body (html), baseUrl, path, index, noindex, viewer, private, crumbs [{label, href}], scripts [rel],
+ * jsonLd (structured data for the page), image (its share image, an https URL), ogType */
+function renderPage(o) {
+ // The front page's title says what the site is (search results show it; at most 60 characters).
+ const title = o.title || (o.path === '/' ? `${SITE_NAME}: share pictures, fast and open` : SITE_NAME);
+ const viewer = o.viewer || { kind: 'anonymous' };
+ const signedIn = viewer.kind === 'user';
+ const pathNow = o.path || '/';
+ const canonical = `${String(o.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}`;
+ const crumbs = (o.crumbs || []).length
+ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
+ : '';
+ const head = `<meta name="referrer" content="strict-origin-when-cross-origin">
+${appIcon.headTags({ site: 'media-hub' })}
+<link rel="stylesheet" href="${asset('css/app.css')}">
+<link rel="stylesheet" href="${asset('css/pics.css')}">
+${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;
+ const account = signedIn
+ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${encodeURIComponent(pathNow)}">Sign out</a>`
+ : html`<a href="/auth/login?next=${encodeURIComponent(pathNow)}">Sign in with OpenVibe</a>`;
+ const body = `<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
+<main id="main" class="page ovp-page">
+${render(crumbs)}
+${render(o.body)}
+</main>`;
+ return shell.page({
+ name: SITE_NAME,
+ lang: 'en',
+ title,
+ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
+ siteName: SITE_NAME,
+ description: o.description || `${SITE_NAME} — ${TAGLINE}`,
+ canonical,
+ robots: o.noindex ? 'noindex' : (o.index ? 'index, follow' : 'noindex, nofollow'),
+ ...(o.jsonLd ? { jsonLd: o.jsonLd } : {}),
+ ...(o.image ? { image: o.image, imageAlt: title } : {}),
+ ...(o.ogType ? { type: o.ogType } : {}),
+ home: '/',
+ navLinks: NAV_LINKS,
+ navbar: {
+ service: 'media-hub',
+ apiBase: NETWORK_URL,
+ links: NAV_LINKS.map((l) => ({ ...l, active: l.href === '/' ? o.path === '/' : String(o.path || '').startsWith(l.href) })),
+ history: { type: 'page', title },
+ sessionUrl: '/auth/me',
+ loginUrl: '/auth/login?next={path}',
+ logoutUrl: '/auth/logout?next={path}',
+ silentLogin: `${String(o.baseUrl).replace(/\/+$/, '')}/auth/login?silent=1&next={url}`,
+ },
+ footer: { service: 'media-hub', variant: 'compact', brandName: SITE_NAME, updates: '/updates' },
+ head,
+ body,
+ });
+}
+
+// The site's own places in the navbar: upload, your pictures and your albums (those two ask you to sign in).
+const NAV_LINKS = [{ label: 'Upload', href: '/' }, { label: 'Your pictures', href: '/mine' }, { label: 'Albums', href: '/albums' }];
+
+/** Send a Pics page. Signed-out pages are public; a session cookie makes a page private, no-store. */
+function send(res, status, o) {
+ if (!res.get('Cache-Control')) res.set('Cache-Control', o.cache || cache.htmlHeaders({ private: o.private, maxAge: 300 }));
+ res.set('Vary', 'Host');
+ res.status(status).type('html').send(renderPage(o));
+}
+
+module.exports = { renderPage, send, asset, assetVersion, SITE_NAME, TAGLINE, raw, html };
new file mode 100644
new file mode 100644
@@ -0,0 +1,501 @@
@@ -0,0 +1,501 @@
+'use strict';
+
+/**
+ * openvibe.pics — OpenVibe.Pics, served by this process for Host: openvibe.pics (server/app.js). Every page works
+ * without JavaScript; public/js/pics.js adds drag-and-drop, paste and several files at once on top of the same API.
+ *
+ * / upload (signed in; signed out too when MEDIAHUB_PICS_ANONYMOUS=1) and your latest pictures
+ * /i/<slug> a picture: the links to share and embed, edit, add to an album, delete, report
+ * /i/<slug>.<ext> the picture itself (what the links embed), read from Media and served from here
+ * /i/<slug>/thumb.webp its 640-pixel thumbnail
+ * /mine, /albums your pictures and albums; /a/<slug> an album
+ * /staff the report queue (Network admin and global_mod): restore, hide, take down
+ * /api/v1/… the API (server/pics/api.js)
+ * /robots.txt /llms.txt /terms /privacy /dmca /updates
+ *
+ * Pictures are unlisted: a page or a file is reachable by its link and never listed or indexed. A hidden picture is
+ * 404 for everyone but its uploader and staff; a removed one is 410. The picture files are served with
+ * `nosniff`, a sandboxing CSP and a 10-minute public cache, so a hidden picture leaves caches within minutes.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const cache = require('openvibe-shared/cache-policy');
+const seo = require('openvibe-shared/seo');
+const ovServe = require('openvibe-shared/serve');
+const frame = require('openvibe-shared/frame');
+const { asyncRouter } = require('../http/router');
+const { sameOrigin } = require('../http/principal');
+const { boundaryOf, readFormData } = require('../http/form-upload');
+const { formatBytes } = require('../drive/rules');
+const { html, raw, notice, time } = require('../render/html');
+const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
+const { send, assetVersion, SITE_NAME } = require('./layout');
+const store = require('./store');
+const { createPicsApi } = require('./api');
+
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const FILE_CACHE = 'public, max-age=600, s-maxage=600';
+const KIND = { jpg: 'JPEG', png: 'PNG', gif: 'GIF', webp: 'WebP', avif: 'AVIF' };
+
+function createPicsRoutes(ctx) {
+ const { config, s, media, log } = ctx;
+ const pics = ctx.pics;
+ const cfg = config.pics;
+ const base = cfg.url;
+ const host = (() => { try { return new URL(base).hostname; } catch { return 'openvibe.pics'; } })();
+ const r = asyncRouter();
+
+ const hasSessionCookie = (req) => Boolean(req.cookies && (req.cookies[ACCESS_COOKIE] || req.cookies[REFRESH_COOKIE]));
+ const page = (req, res, o, status = 200) => send(res, status, {
+ ...o, viewer: req.viewer, private: o.private !== false && hasSessionCookie(req), baseUrl: base, path: req.originalUrl,
+ });
+ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
+ /** Who is asking, as the service wants it: { requester, staff } for a person, { ip } signed out. */
+ const whoOf = (req) => (signedIn(req) ? { requester: `user:${req.viewer.subject}`, staff: pics.isStaff(req.viewer), ip: req.ip } : { ip: req.ip });
+ const signInLink = (next, text = 'Sign in with OpenVibe') => html`<a class="ovp-btn ovp-btn--primary" href="/auth/login?next=${encodeURIComponent(next)}">${text}</a>`;
+ const notFound = (req, res, status = 404) => page(req, res, {
+ title: status === 410 ? 'Taken down' : 'Not found', cache: cache.htmlHeaders({ private: true }),
+ body: status === 410
+ ? html`<h1>This picture was taken down</h1><p class="muted">It broke the <a href="/terms">rules</a> or the law and is no longer on OpenVibe.Pics.</p>`
+ : html`<h1>Not found</h1><p class="muted">No picture or page here. <a href="/">Upload one</a>.</p>`,
+ }, status);
+ const mustSignIn = (why) => (req, res, next) => (signedIn(req) ? next() : page(req, res, {
+ title: 'Sign in', body: html`<h1>Sign in first</h1><p>${why}</p><p>${signInLink(req.originalUrl)}</p>`,
+ }, 401));
+ function sameSite(req, res, back) {
+ if (sameOrigin(req, base)) return true;
+ page(req, res, { title: 'Not from this site', body: html`<h1>That came from somewhere else</h1><p class="muted">A form that changes something has to come from ${host} itself. <a href="${back}">Go back</a> and send it again.</p>` }, 403);
+ return false;
+ }
+ /** The per-caller budget the API uses, with a page of its own for a refusal (as http/pages.js budgeted()). */
+ const budgeted = (name) => {
+ const limit = ctx.limits.budget(name);
+ return (req, res, next) => {
+ let settled = false;
+ limit(req, {
+ statusCode: 0, setHeader: () => {}, getHeader: () => undefined, removeHeader: () => {},
+ end: () => {
+ if (settled) return;
+ settled = true;
+ page(req, res, { title: 'Too many requests', body: html`<h1>Too many requests just now</h1><p class="muted">Wait a minute and send it again.</p>` }, 429);
+ },
+ }, (err) => { if (settled) return undefined; settled = true; return err ? next(err) : next(); });
+ return undefined;
+ };
+ };
+
+ // ── The shell's /shared copy and this site's stylesheet and script (public/) ──
+ r.use('/shared', ovServe.handler());
+ r.use(express.static(PUBLIC_DIR, {
+ index: false, redirect: false,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
+ },
+ }));
+
+ // ── The API on this host ────────────────────────────────
+ r.use('/api/v1', createPicsApi(ctx));
+ r.use('/api', (req, res) => res.status(404).type('application/problem+json').send(JSON.stringify({ type: 'about:blank', title: 'Not found', status: 404, code: 'route.not_found' })));
+
+ // ── The files ───────────────────────────────────────────
+ async function serveObject(req, res, row, objectId, type) {
+ const got = await media.bytes(objectId);
+ if (!got.ok) return res.status(got.status === 404 || got.status === 410 ? 404 : 502).set('Cache-Control', 'no-store').type('text/plain').send(got.status === 404 || got.status === 410 ? 'Not found' : 'OpenVibe.Media did not answer.');
+ const up = got.res;
+ res.status(200).set({
+ 'Content-Type': type,
+ 'X-Content-Type-Options': 'nosniff',
+ 'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
+ 'Cross-Origin-Resource-Policy': 'cross-origin',
+ 'Access-Control-Allow-Origin': '*',
+ 'X-Robots-Tag': 'noindex',
+ // Hidden pictures leave shared caches within ten minutes; their uploader and staff get no-store copies.
+ 'Cache-Control': row.state === 'active' ? FILE_CACHE : 'private, no-store',
+ 'Content-Disposition': `inline; filename="${row.slug}.${row.ext}"`,
+ ETag: `"${row.sha256.slice(0, 32)}"`,
+ });
+ const len = up.headers.get('content-length');
+ if (len) res.set('Content-Length', len);
+ if (req.method === 'HEAD' || !up.body) return res.end();
+ const { Readable } = require('stream');
+ Readable.fromWeb(up.body).on('error', () => res.destroy()).pipe(res);
+ if (row.state === 'active') pics.viewed(row).catch(() => {});
+ return undefined;
+ }
+
+ async function picture(req, res) {
+ const slug = String(req.params.slug || '');
+ if (!pics.isSlug(slug)) return { row: null, status: 404 };
+ const row = await store.imageBySlug(s, slug);
+ if (!row) return { row: null, status: 404 };
+ if (row.state === 'removed') return { row, status: 410 };
+ if (!pics.canSee(row, whoOf(req))) return { row: null, status: 404 };
+ return { row, status: 200 };
+ }
+
+ r.get(/^\/i\/([2-9a-km-zA-HJ-NP-Z]{8})\.(jpg|png|gif|webp|avif)$/, async (req, res) => {
+ req.params.slug = req.params[0];
+ const { row, status } = await picture(req, res);
+ if (!row || status !== 200 || row.ext !== req.params[1]) return res.status(status === 410 ? 410 : 404).set('Cache-Control', 'public, max-age=60').type('text/plain').send(status === 410 ? 'Taken down' : 'Not found');
+ if (req.get('if-none-match') === `"${row.sha256.slice(0, 32)}"` && row.state === 'active') return res.status(304).set('Cache-Control', FILE_CACHE).end();
+ return serveObject(req, res, row, row.object_id, row.mime_type);
+ });
+
+ r.get('/i/:slug/thumb.webp', async (req, res) => {
+ const { row, status } = await picture(req, res);
+ if (!row || status !== 200) return res.status(status === 410 ? 410 : 404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('Not found');
+ return row.thumb_object_id ? serveObject(req, res, row, row.thumb_object_id, 'image/webp') : res.redirect(302, `/i/${row.slug}.${row.ext}`);
+ });
+
+ // ── Pages: forms are urlencoded, uploads multipart ──────
+ r.use(express.urlencoded({ extended: false, limit: '64kb' }));
+
+ const thumb = (row) => html`<a class="ovp-tile${row.state !== 'active' ? ' is-hidden' : ''}" href="/i/${row.slug}"><img src="/i/${row.slug}/thumb.webp" alt="${row.title || 'Picture'}" loading="lazy" decoding="async" width="${Math.min(Number(row.width), 640)}" height="${Math.round(Math.min(Number(row.width), 640) * Number(row.height) / Math.max(1, Number(row.width)))}">${row.state !== 'active' ? html`<span class="ovp-flag">hidden</span>` : ''}</a>`;
+ const grid = (rows, empty) => (rows.length ? html`<div class="ovp-grid">${rows.map(thumb)}</div>` : html`<p class="muted">${empty}</p>`);
+
+ function uploadForm(req, { compact = false } = {}) {
+ const max = signedIn(req) ? cfg.maxImageBytes : cfg.anonymousMaxImageBytes;
+ return html`<form class="ovp-drop${compact ? ' ovp-drop--compact' : ''}" method="post" action="/upload" enctype="multipart/form-data" data-pics-upload data-max="${max}">
+<div class="ovp-drop-inner">
+<span class="ovp-drop-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="40" height="40"><path fill="currentColor" d="M19 5v14H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Zm-4.86 8.86-3 3.87L9 13.14 6 17h12l-3.86-5.14Z"/></svg></span>
+<p class="ovp-drop-title">Drop pictures here, paste one, or choose files</p>
+<p class="ovp-drop-hint">JPEG, PNG, GIF, WebP or AVIF, up to ${formatBytes(max)} each. Location and camera details are removed.</p>
+<label class="ovp-btn ovp-btn--primary ovp-file"><input type="file" name="file" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" required data-pics-input> Choose a picture</label>
+<div class="ovp-drop-fields"><label for="up-title">Title (optional)</label><input id="up-title" name="title" type="text" maxlength="120" placeholder="What is it?"></div>
+<button class="ovp-btn ovp-btn--primary ovp-nojs-submit" type="submit">Upload</button>
+</div>
+<ol class="ovp-results" data-pics-results aria-live="polite"></ol>
+</form>`;
+ }
+
+ // ── Home ────────────────────────────────────────────────
+ r.get('/', async (req, res) => {
+ const mine = signedIn(req) ? (await store.imagesOf(s, whoOf(req).requester, { limit: 12 })).rows : [];
+ const canUpload = signedIn(req) || cfg.anonymous;
+ return page(req, res, {
+ index: true,
+ description: 'Upload a picture and get a link to share or embed anywhere. Albums, no ads, open source, with location and camera details removed from every picture.',
+ scripts: canUpload ? ['js/pics.js'] : [],
+ body: html`<header class="ovp-hero"><p class="ovp-eyebrow">${SITE_NAME}</p>
+<h1>Share pictures. <span class="ovp-accent">Fast, open, yours.</span></h1>
+<p class="ovp-lede">Drop a picture, get a link to share or embed anywhere: on a forum, in a chat, on your site. No ads, no feed, and nothing about you hidden in the file.</p></header>
+${canUpload ? uploadForm(req) : html`<section class="ovp-drop ovp-drop--signin"><div class="ovp-drop-inner"><p class="ovp-drop-title">Sign in to upload</p><p class="ovp-drop-hint">Your OpenVibe account keeps your pictures and albums together, so you can delete them whenever you like.</p><p>${signInLink('/')}</p></div></section>`}
+${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><div class="ovp-section-head"><h2 id="h-latest">Your latest pictures</h2><a href="/mine">All of them</a></div>${grid(mine, 'Nothing yet: your uploads show up here.')}</section>` : ''}
+<section class="ovp-section ovp-features" aria-label="What you get">
+<div class="ovp-feature"><h3>Links that work everywhere</h3><p>A page to share, a direct link for <code><img></code>, and Markdown, BBCode and HTML ready to copy.</p></div>
+<div class="ovp-feature"><h3>Unlisted by default</h3><p>Only people with the link see a picture. Nothing is listed, ranked or indexed.</p></div>
+<div class="ovp-feature"><h3>Clean files</h3><p>Every picture is re-encoded, and its GPS position, camera serial and other hidden details are removed before anyone sees it.</p></div>
+<div class="ovp-feature"><h3>Albums</h3><p>Put pictures together and share the album with one link.</p></div>
+</section>`,
+ });
+ });
+
+ // ── Upload (the no-JavaScript form; the script uses the API) ──
+ const spoolDir = () => config.tmpDir || path.join(os.tmpdir(), 'mediahub-uploads');
+ const uploadFailed = (req, res, detail, status = 422) => page(req, res, {
+ title: 'Upload failed', scripts: ['js/pics.js'],
+ body: html`<h1>That upload did not work</h1>${notice(detail, 'warn')}${uploadForm(req, { compact: true })}`,
+ }, status);
+
+ r.post('/upload', budgeted('pics.image.upload'), async (req, res) => {
+ if (!signedIn(req) && !cfg.anonymous) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in to upload</h1><p>${signInLink('/')}</p>` }, 401);
+ if (!sameSite(req, res, '/')) return undefined;
+ const type = String(req.get('content-type') || '');
+ const boundary = /^multipart\/form-data/i.test(type) ? boundaryOf(type) : null;
+ if (!boundary) return uploadFailed(req, res, 'That was not a picture upload. Choose a picture and send the form again.');
+ const max = (signedIn(req) ? cfg.maxImageBytes : cfg.anonymousMaxImageBytes) + 64 * 1024;
+ fs.mkdirSync(spoolDir(), { recursive: true });
+ const tmp = path.join(spoolDir(), `pic-${crypto.randomBytes(8).toString('hex')}`);
+ const got = await readFormData(req, { boundary, filePath: tmp, maxBytes: max });
+ try {
+ if (got.error === 'too_large') return uploadFailed(req, res, `A picture can be up to ${formatBytes(max - 64 * 1024)}.`, 413);
+ if (got.error || !got.file || !got.file.size) return uploadFailed(req, res, 'No picture arrived. Choose one and send the form again.');
+ const out = await pics.upload(whoOf(req), { buffer: fs.readFileSync(got.file.path), name: got.file.filename }, { title: got.fields.title || '' });
+ if (!out.ok) return uploadFailed(req, res, out.detail, out.status);
+ if (out.deleteKey) return picturePage(req, res, out.image, { deleteKey: out.deleteKey, fresh: true });
+ return res.redirect(303, `/i/${out.image.slug}?new=1`);
+ } finally {
+ fs.rm(tmp, { force: true }, () => {});
+ }
+ });
+
+ // ── A picture ───────────────────────────────────────────
+ function copyRow(label, value, id) {
+ return html`<div class="ovp-copy"><label for="${id}">${label}</label><div class="ovp-copy-row"><input id="${id}" type="text" readonly value="${value}" spellcheck="false"><button class="ovp-btn" type="button" data-copy="${id}">Copy</button></div></div>`;
+ }
+
+ async function picturePage(req, res, row, { deleteKey = null, fresh = false, message = null } = {}) {
+ const who = whoOf(req);
+ const owner = pics.isOwner(row, who);
+ const L = pics.links(row);
+ const albums = owner ? await store.albumsOf(s, who.requester) : [];
+ const holding = owner ? await store.albumsHolding(s, row.id) : [];
+ const title = row.title || 'Untitled picture';
+ const hidden = row.state !== 'active';
+ return page(req, res, {
+ title, noindex: true,
+ description: row.description || `${title}: a picture on ${SITE_NAME}.`,
+ image: hidden ? undefined : L.direct, ogType: 'article',
+ scripts: ['js/pics.js'],
+ private: deleteKey ? true : undefined,
+ body: html`${message ? notice(message, 'ok') : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}
+${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}
+${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff look at reports about it. Only you and staff can see it.' : 'This picture was taken down.', 'warn') : ''}
+<article class="ovp-picture">
+<h1 class="ovp-title">${title}</h1>
+<figure class="ovp-figure"><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${title}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>
+<p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>
+${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
+</article>
+<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>
+${copyRow('Link to this page', L.page, 'c-page')}${copyRow('Direct link (for <img> and forums)', L.direct, 'c-direct')}${copyRow('Markdown', L.markdown, 'c-md')}${copyRow('BBCode (forums)', L.bbcode, 'c-bb')}${copyRow('HTML', L.html, 'c-html')}
+</section>
+${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-yours"><h2 id="h-yours">Yours</h2>
+<form class="ovp-form" method="post" action="/i/${row.slug}/edit">
+<label for="e-title">Title</label><input id="e-title" name="title" type="text" maxlength="120" value="${row.title}">
+<label for="e-desc">Description</label><textarea id="e-desc" name="description" rows="3" maxlength="2000">${row.description}</textarea>
+<p><button class="ovp-btn ovp-btn--primary" type="submit">Save</button></p></form>
+<form class="ovp-form ovp-inline" method="post" action="/i/${row.slug}/album">
+<label for="e-album">Add to an album</label>
+<select id="e-album" name="album">${albums.map((a) => html`<option value="${a.slug}">${a.title}</option>`)}<option value="">A new album…</option></select>
+<input name="new_title" type="text" maxlength="120" placeholder="New album name" aria-label="New album name">
+<button class="ovp-btn" type="submit">Add</button></form>
+${holding.length ? html`<p class="muted">In ${holding.map((a, i) => html`${i ? ', ' : ''}<a href="/a/${a.slug}">${a.title}</a>`)}.</p>` : ''}
+<form class="ovp-form ovp-inline" method="post" action="/i/${row.slug}/delete" data-confirm="Delete this picture for good?"><button class="ovp-btn ovp-btn--danger" type="submit">Delete this picture</button> <span class="muted">Its links stop working at once.</span></form>
+</section>` : ''}
+${!owner && !hidden ? html`<details class="ovp-report"><summary>Report this picture</summary>
+<form class="ovp-form" method="post" action="/i/${row.slug}/report">
+<fieldset><legend>What is wrong with it?</legend>
+${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name="reason" value="${r}" required> ${pics.REASON_TEXT[r]}</label>`)}
+</fieldset>
+<label for="r-note">Anything staff should know (optional)</label><textarea id="r-note" name="note" rows="2" maxlength="500"></textarea>
+<p><button class="ovp-btn" type="submit">Send the report</button> <span class="muted">Copyright owners can also send a notice through <a href="/dmca">the DMCA page</a>.</span></p>
+</form></details>` : ''}`,
+ });
+ }
+
+ r.get('/i/:slug', async (req, res) => {
+ const { row, status } = await picture(req, res);
+ if (!row || status !== 200) return notFound(req, res, status);
+ return picturePage(req, res, row);
+ });
+
+ const ownPicture = async (req, res) => {
+ const { row } = await picture(req, res);
+ if (!row || !pics.isOwner(row, whoOf(req))) { notFound(req, res); return null; }
+ return row;
+ };
+
+ r.post('/i/:slug/edit', mustSignIn('Sign in to change your picture.'), budgeted('pics.image.write'), async (req, res) => {
+ if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
+ const row = await ownPicture(req, res);
+ if (!row) return undefined;
+ await pics.edit(row, { title: req.body.title, description: req.body.description });
+ return res.redirect(303, `/i/${row.slug}`);
+ });
+
+ r.post('/i/:slug/album', mustSignIn('Sign in to use albums.'), budgeted('pics.image.write'), async (req, res) => {
+ if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
+ const row = await ownPicture(req, res);
+ if (!row) return undefined;
+ const owner = whoOf(req).requester;
+ let album = req.body.album ? await store.albumBySlug(s, String(req.body.album)) : null;
+ if (album && album.owner !== owner) album = null;
+ if (!album) {
+ const made = await pics.createAlbum(owner, { title: req.body.new_title });
+ if (!made.ok) return picturePage(req, res, row, { message: made.detail });
+ album = made.album;
+ }
+ const added = await pics.addToAlbum(album, row);
+ if (!added.ok) return picturePage(req, res, row, { message: added.detail });
+ return res.redirect(303, `/a/${album.slug}`);
+ });
+
+ r.post('/i/:slug/delete', budgeted('pics.image.write'), async (req, res) => {
+ if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
+ const { row } = await picture(req, res);
+ const byKey = row && pics.deleteKeyMatches(row, String(req.body.key || ''));
+ if (!row || !(pics.isOwner(row, whoOf(req)) || byKey)) return notFound(req, res);
+ await pics.remove(row);
+ return page(req, res, { title: 'Deleted', body: html`<h1>Deleted</h1><p>The picture and its links are gone.</p><p><a class="ovp-btn ovp-btn--primary" href="/">Upload another</a>${signedIn(req) ? html` <a class="ovp-btn" href="/mine">Your pictures</a>` : ''}</p>` });
+ });
+
+ // The delete link a signed-out uploader keeps: a page with one button (a GET never deletes).
+ r.get('/i/:slug/delete', async (req, res) => {
+ const { row } = await picture(req, res);
+ const key = String(req.query.key || '');
+ if (!row || !pics.deleteKeyMatches(row, key)) return notFound(req, res);
+ return page(req, res, {
+ title: 'Delete this picture', noindex: true, private: true,
+ body: html`<h1>Delete this picture?</h1><p><img class="ovp-confirm-thumb" src="/i/${row.slug}/thumb.webp" alt=""></p>
+<form method="post" action="/i/${row.slug}/delete"><input type="hidden" name="key" value="${key}"><button class="ovp-btn ovp-btn--danger" type="submit">Delete it for good</button> <a href="/i/${row.slug}">Keep it</a></form>`,
+ });
+ });
+
+ r.post('/i/:slug/report', budgeted('pics.report.create'), async (req, res) => {
+ if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
+ const { row } = await picture(req, res);
+ if (!row || row.state !== 'active') return notFound(req, res);
+ const who = whoOf(req);
+ const out = await pics.report(row, { reporter: who.requester || pics.addressId(who.ip), reason: String(req.body.reason || ''), note: req.body.note });
+ if (!out.ok) return picturePage(req, res, row, { message: out.detail });
+ return page(req, res, {
+ title: 'Report sent', private: true,
+ body: html`<h1>Thank you</h1><p>${out.added ? 'Your report was sent to OpenVibe staff.' : 'You had already reported this picture; staff have your report.'}${out.hidden ? ' The picture is hidden until staff look at it.' : ''}</p><p><a href="/">Back to ${SITE_NAME}</a></p>`,
+ });
+ });
+
+ // ── Your pictures and albums ────────────────────────────
+ r.get('/mine', mustSignIn('Your pictures belong to your OpenVibe account.'), async (req, res) => {
+ const before = /^pic_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.query.before || '')) ? String(req.query.before) : null;
+ const owner = whoOf(req).requester;
+ const out = await store.imagesOf(s, owner, { limit: 48, before });
+ const use = await store.usageOf(s, owner);
+ return page(req, res, {
+ title: 'Your pictures', scripts: ['js/pics.js'],
+ body: html`<div class="ovp-section-head"><h1>Your pictures</h1><span class="muted">${use.images} picture${use.images === 1 ? '' : 's'} · ${formatBytes(use.bytes)} of ${formatBytes(cfg.quotaBytes)}</span></div>
+${uploadForm(req, { compact: true })}
+${grid(out.rows, 'Nothing yet. Upload a picture above.')}
+${out.next ? html`<p><a class="ovp-btn" href="/mine?before=${out.next}">Older pictures</a></p>` : ''}`,
+ });
+ });
+
+ r.get('/albums', mustSignIn('Albums belong to your OpenVibe account.'), async (req, res) => {
+ const albums = await store.albumsOf(s, whoOf(req).requester);
+ return page(req, res, {
+ title: 'Your albums',
+ body: html`<h1>Your albums</h1>
+<form class="ovp-form ovp-inline" method="post" action="/albums"><label for="a-title">New album</label><input id="a-title" name="title" type="text" maxlength="120" required placeholder="Trip to the coast"><button class="ovp-btn ovp-btn--primary" type="submit">Create</button></form>
+${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class="ovp-album" href="/a/${a.slug}">${a.cover_slug ? html`<img src="/i/${a.cover_slug}/thumb.webp" alt="" loading="lazy">` : html`<span class="ovp-album-empty" aria-hidden="true"></span>`}<span class="ovp-album-title">${a.title}</span><span class="muted">${a.image_count} picture${Number(a.image_count) === 1 ? '' : 's'}</span></a>`)}</div>`
+ : html`<p class="muted">No albums yet. Create one here, or add a picture to a new album from its page.</p>`}`,
+ });
+ });
+
+ r.post('/albums', mustSignIn('Sign in to use albums.'), budgeted('pics.album.create'), async (req, res) => {
+ if (!sameSite(req, res, '/albums')) return undefined;
+ const made = await pics.createAlbum(whoOf(req).requester, { title: req.body.title });
+ if (!made.ok) return page(req, res, { title: 'Album', body: html`<h1>Your albums</h1>${notice(made.detail, 'warn')}<p><a href="/albums">Back</a></p>` }, made.status);
+ return res.redirect(303, `/a/${made.album.slug}`);
+ });
+
+ async function ownAlbum(req, res) {
+ const slug = String(req.params.slug || '');
+ const album = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;
+ if (!album || album.owner !== whoOf(req).requester) { notFound(req, res); return null; }
+ return album;
+ }
+
+ r.get('/a/:slug', async (req, res) => {
+ const slug = String(req.params.slug || '');
+ const album = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;
+ if (!album) return notFound(req, res);
+ const owner = album.owner === whoOf(req).requester;
+ const rows = await store.albumImages(s, album.id, { visibleOnly: !owner });
+ return page(req, res, {
+ title: album.title, noindex: true, scripts: ['js/pics.js'],
+ image: rows[0] ? `${base}/i/${rows[0].slug}.${rows[0].ext}` : undefined,
+ body: html`<div class="ovp-section-head"><h1>${album.title}</h1><span class="muted">${rows.length} picture${rows.length === 1 ? '' : 's'}</span></div>
+${album.description ? html`<p class="ovp-desc">${album.description}</p>` : ''}
+${copyRow('Link to this album', `${base}/a/${album.slug}`, 'c-album')}
+${grid(rows, owner ? 'Empty. Add pictures from their pages.' : 'Nothing to show here.')}
+${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-manage"><h2 id="h-manage">Manage</h2>
+<form class="ovp-form" method="post" action="/a/${album.slug}/edit"><label for="al-title">Name</label><input id="al-title" name="title" type="text" maxlength="120" value="${album.title}" required>
+<label for="al-desc">Description</label><textarea id="al-desc" name="description" rows="2" maxlength="2000">${album.description}</textarea><p><button class="ovp-btn ovp-btn--primary" type="submit">Save</button></p></form>
+${rows.length ? html`<form class="ovp-form ovp-inline" method="post" action="/a/${album.slug}/remove"><label for="al-rm">Take out of the album</label><select id="al-rm" name="image">${rows.map((x) => html`<option value="${x.slug}">${x.title || x.slug}</option>`)}</select><button class="ovp-btn" type="submit">Take out</button></form>` : ''}
+<form class="ovp-form ovp-inline" method="post" action="/a/${album.slug}/delete" data-confirm="Delete this album? The pictures stay."><button class="ovp-btn ovp-btn--danger" type="submit">Delete the album</button> <span class="muted">The pictures in it stay.</span></form>
+</section>` : ''}`,
+ });
+ });
+
+ r.post('/a/:slug/edit', mustSignIn('Sign in to use albums.'), budgeted('pics.image.write'), async (req, res) => {
+ if (!sameSite(req, res, `/a/${req.params.slug}`)) return undefined;
+ const album = await ownAlbum(req, res);
+ if (!album) return undefined;
+ const title = String(req.body.title || '').replace(/\s+/g, ' ').trim().slice(0, pics.LIMITS.albumTitle);
+ await store.updateAlbum(s, album.id, { title: title || null, description: req.body.description == null ? null : String(req.body.description).trim().slice(0, pics.LIMITS.description) });
+ return res.redirect(303, `/a/${album.slug}`);
+ });
+
+ r.post('/a/:slug/remove', mustSignIn('Sign in to use albums.'), budgeted('pics.image.write'), async (req, res) => {
+ if (!sameSite(req, res, `/a/${req.params.slug}`)) return undefined;
+ const album = await ownAlbum(req, res);
+ if (!album) return undefined;
+ const image = pics.isSlug(req.body.image) ? await store.imageBySlug(s, String(req.body.image)) : null;
+ if (image) await store.removeFromAlbum(s, album.id, image.id);
+ return res.redirect(303, `/a/${album.slug}`);
+ });
+
+ r.post('/a/:slug/delete', mustSignIn('Sign in to use albums.'), budgeted('pics.image.write'), async (req, res) => {
+ if (!sameSite(req, res, `/a/${req.params.slug}`)) return undefined;
+ const album = await ownAlbum(req, res);
+ if (!album) return undefined;
+ await store.deleteAlbum(s, album.id);
+ return res.redirect(303, '/albums');
+ });
+
+ // ── Staff ───────────────────────────────────────────────
+ const staffOnly = (req, res, next) => (signedIn(req) && pics.isStaff(req.viewer) ? next() : notFound(req, res));
+ r.get('/staff', staffOnly, async (req, res) => {
+ const state = ['hidden', 'reported', 'removed'].includes(String(req.query.state)) ? String(req.query.state) : 'hidden';
+ const rows = await store.staffQueue(s, { state });
+ const counts = await store.staffCounts(s);
+ const items = [];
+ for (const row of rows) items.push({ row, reports: await store.reportsFor(s, row.id) });
+ const tab = (k, label) => (k === state ? html`<span class="ovp-tab on" aria-current="page">${label} (${counts[k]})</span>` : html`<a class="ovp-tab" href="/staff?state=${k}">${label} (${counts[k]})</a>`);
+ return page(req, res, {
+ title: 'Pics reports', private: true,
+ body: html`<h1>Reports</h1><nav class="ovp-tabs">${tab('hidden', 'Hidden, waiting')}${tab('reported', 'Reported, still up')}${tab('removed', 'Taken down')}</nav>
+${items.length ? items.map(({ row, reports }) => html`<article class="ovp-staff">
+${row.state !== 'removed' ? html`<a href="/i/${row.slug}"><img src="/i/${row.slug}/thumb.webp" alt="" loading="lazy"></a>` : html`<span class="ovp-album-empty" aria-hidden="true"></span>`}
+<div><p><b>${row.title || row.slug}</b> · ${row.state} · ${row.report_count} report${Number(row.report_count) === 1 ? '' : 's'} · ${row.owner ? 'by an account' : 'signed out'} · ${time(row.created_at)}</p>
+<ul>${reports.map((x) => html`<li><b>${pics.REASON_TEXT[x.reason] || x.reason}</b>${x.note ? html`: ${x.note}` : ''} <span class="muted">${time(x.created_at)}</span></li>`)}</ul>
+${row.state !== 'removed' ? html`<form class="ovp-inline" method="post" action="/staff/i/${row.slug}">
+${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="restore">Looks fine: show it</button>` : html`<button class="ovp-btn" name="action" value="hide">Hide</button>`}
+<input name="reason" type="text" maxlength="60" placeholder="Why (kept with the blocklist)" aria-label="Why it is taken down">
+<button class="ovp-btn ovp-btn--danger" name="action" value="takedown">Take down and block</button></form>` : ''}</div></article>`) : html`<p class="muted">Nothing here.</p>`}
+<p class="small muted">Taking a picture down deletes its bytes and puts its hashes on the blocklist: the same picture, or a resized or re-compressed copy, cannot be uploaded again. Child sexual abuse material must also be reported to NCMEC's CyberTipline.</p>`,
+ });
+ });
+
+ r.post('/staff/i/:slug', staffOnly, async (req, res) => {
+ if (!sameSite(req, res, '/staff')) return undefined;
+ const slug = String(req.params.slug || '');
+ const row = pics.isSlug(slug) ? await store.imageBySlug(s, slug) : null;
+ if (!row) return notFound(req, res);
+ await pics.moderate(row, String(req.body.action || ''), { by: whoOf(req).requester, reason: req.body.reason });
+ return res.redirect(303, `/staff?state=${row.state === 'active' ? 'reported' : 'hidden'}`);
+ });
+
+ // ── Discovery, legal, update log ────────────────────────
+ r.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
+ .send(`# openvibe.pics: pictures are unlisted; only the home page is for search engines.\n${seo.robotsTxt({ disallow: ['/i/', '/a/', '/mine', '/albums', '/staff', '/api/', '/auth/', '/upload'] })}`));
+ r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
+ name: SITE_NAME,
+ summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, no ads.',
+ details: 'Pictures are unlisted (reachable only by their link). Each one is re-encoded with its metadata removed. API: POST /api/v1/images with the picture as the body (an OpenVibe sign-in token as a Bearer).',
+ sections: [{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }, { title: 'Terms', url: `${base}/terms` }, { title: 'DMCA', url: `${base}/dmca` }] }],
+ })));
+ r.get('/updates', (req, res) => page(req, res, {
+ index: true, title: 'What shipped on OpenVibe.Pics', description: 'Every change deployed to OpenVibe.Pics, newest first.',
+ body: raw(frame.updatesBody({ service: 'media-hub', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`),
+ }));
+ const legal = require('openvibe-shared/legal');
+ r.get(legal.PATHS, legal.handler({ id: 'media-hub', service: 'media-hub', host, name: SITE_NAME, profile: 'ugc' }));
+
+ r.use((req, res) => notFound(req, res));
+ // eslint-disable-next-line no-unused-vars
+ r.use((err, req, res, _next) => {
+ log.error('[OpenVibe.Pics]', err && err.message ? err.message.slice(0, 300) : err);
+ if (res.headersSent) return undefined;
+ if (err && err.type === 'entity.too.large') return res.status(413).type('text/plain').send('That request was too large.');
+ return page(req, res, { title: 'Something went wrong', cache: cache.htmlHeaders({ private: true }), body: html`<h1>Something went wrong on our side</h1><p class="muted">Try again in a moment.</p>` }, 500);
+ });
+ return r;
+}
+
+module.exports = { createPicsRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,246 @@
@@ -0,0 +1,246 @@
+'use strict';
+
+/**
+ * OpenVibe.Pics: the rules. The pages (server/pics/pages.js) and the API (server/pics/api.js) both come through here,
+ * so a form and an API call can never mean different things.
+ *
+ * upload a picture from a person (or signed out, when MEDIAHUB_PICS_ANONYMOUS=1): size, daily count and
+ * quota checks, then images.prepare() (decode, re-encode without metadata, thumbnail, hashes), then
+ * the blocklist, then two private Media objects and the row. A signed-out upload gets a delete key,
+ * shown once and kept only as its sha256.
+ * report one per reporter per picture. `child_safety` hides the picture at once; any other reason hides it
+ * at MEDIAHUB_PICS_REPORTS_TO_HIDE distinct reports. Hidden is 404 for everyone but its uploader and
+ * staff, until staff decide. The bytes are private Media objects that only this service reads and serves
+ * (server/pics/pages.js /i/<slug>.<ext>), so hiding a picture stops it everywhere at once, apart from
+ * what a cache already holds for its last few minutes.
+ * takedown staff: the picture is removed, its Media objects deleted, and its hashes go on the blocklist so the
+ * same picture (or a near-duplicate) cannot be uploaded again.
+ * remove the uploader (or a signed-out uploader with the delete key) deletes it, bytes and all.
+ * sweep signed-out pictures nobody has viewed for MEDIAHUB_PICS_ANONYMOUS_IDLE_DAYS are deleted.
+ *
+ * Who may see a picture: active ones, anyone with the link; hidden ones, the uploader and staff; removed ones, staff.
+ */
+const crypto = require('crypto');
+const images = require('./images');
+const store = require('./store');
+const { isStaff } = require('../drive/staff');
+
+const SLUG_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ';
+const SLUG_RE = /^[2-9a-km-zA-HJ-NP-Z]{8}$/;
+const REASONS = ['child_safety', 'illegal', 'copyright', 'sexual', 'violence', 'harassment', 'private_info', 'spam', 'other'];
+const REASON_TEXT = {
+ child_safety: 'Child sexual abuse or exploitation', illegal: 'Other illegal content', copyright: 'Copyright infringement (see the DMCA page)',
+ sexual: 'Sexual content shared without consent', violence: 'Graphic violence or threats', harassment: 'Harassment or hate',
+ private_info: 'Someone\'s private information', spam: 'Spam or a scam', other: 'Something else',
+};
+const LIMITS = { title: 120, description: 2000, albumTitle: 120, note: 500 };
+// A difference hash this close (of 64 bits) to a blocked one is the same picture, resized, re-compressed or retouched:
+// measured 2026-10-15, such copies of real photos land 0-10 bits away and different photos 23-42.
+const DHASH_DISTANCE = 10;
+
+const slug = () => Array.from(crypto.randomBytes(8), (b) => SLUG_ALPHABET[b % SLUG_ALPHABET.length]).join('');
+const cleanLine = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
+const cleanText = (v, max) => String(v == null ? '' : v).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, max);
+/** A hash with too few or too many set bits is a near-flat picture: its dHash is noise, not an identity. */
+const distinctive = (h) => { const n = images.distance(h, '0000000000000000'); return n >= 8 && n <= 56; };
+
+function createPics({ s, media, config, log = console }) {
+ const cfg = config.pics;
+ const addressKey = crypto.createHash('sha256').update(`pics-address:${config.oauth.clientSecret || 'dev'}`).digest();
+
+ /** A signed-out caller as a stable, keyed hash of their address: counted and deduplicated, never stored as is. */
+ const addressId = (ip) => `ip:${crypto.createHmac('sha256', addressKey).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;
+ const day = () => s.iso().slice(0, 10);
+
+ /** May `who` ({ requester, staff }, see whoOf in pages.js and api.js) see this picture? */
+ function canSee(row, who) {
+ if (!row) return false;
+ if (row.state === 'active') return true;
+ if (who && who.staff) return true;
+ return row.state === 'hidden' && Boolean(who && who.requester && row.owner === who.requester);
+ }
+ const isOwner = (row, viewer) => Boolean(row && row.owner && viewer && viewer.requester && row.owner === viewer.requester);
+
+ async function putObject(buf, { mime, name, owner, anonymous }) {
+ const init = await media.init({ size: buf.length, contentType: mime, name, ownerSubject: owner, visibility: 'private', ownerless: anonymous });
+ if (!init.ok) return init;
+ const id = init.data && (init.data.id || (init.data.object && init.data.object.id));
+ const put = await media.putContent(id, buf);
+ if (!put.ok) { await media.remove(id).catch(() => {}); return put; }
+ const done = await media.complete(id);
+ if (!done.ok) { await media.remove(id).catch(() => {}); return done; }
+ return { ok: true, id };
+ }
+
+ /** Is this picture on the takedown blocklist (exactly, or as a near-duplicate)? */
+ async function blocked(prepared) {
+ const exact = await store.blockedSha(s, [prepared.sha256, prepared.sourceSha256]);
+ if (exact) return exact;
+ if (!distinctive(prepared.dhash)) return null;
+ const near = (await store.blockedDhashes(s)).find((b) => images.distance(b.value, prepared.dhash) <= DHASH_DISTANCE);
+ return near || null;
+ }
+
+ /**
+ * Upload one picture. who: { requester } (a person) or { anonymous: true, ip }. file: { buffer, name }.
+ * → { ok, image, deleteKey? } or { ok: false, status, code, detail }.
+ */
+ async function upload(who, file, { title = '', description = '' } = {}) {
+ const anonymous = !who || !who.requester;
+ if (anonymous && !cfg.anonymous) return { ok: false, status: 401, code: 'pics.sign_in_required', detail: 'Sign in with OpenVibe to upload pictures.' };
+ const buf = file && file.buffer;
+ const max = anonymous ? cfg.anonymousMaxImageBytes : cfg.maxImageBytes;
+ if (!buf || !buf.length) return { ok: false, status: 422, code: 'pics.empty', detail: 'Choose a picture to upload.' };
+ if (buf.length > max) return { ok: false, status: 413, code: 'pics.too_large', detail: `A picture can be up to ${Math.round(max / 1024 / 1024)} MB.` };
+ const uploader = anonymous ? addressId(who && who.ip) : who.requester;
+ const perDay = anonymous ? cfg.anonymousPerDay : cfg.imagesPerDay;
+ if (await store.uploadsToday(s, uploader, day()) >= perDay) return { ok: false, status: 429, code: 'pics.daily_limit', detail: `That is ${perDay} uploads today, the most a day. Try again tomorrow.` };
+ if (!anonymous) {
+ const use = await store.usageOf(s, who.requester);
+ if (use.bytes + buf.length > cfg.quotaBytes) return { ok: false, status: 409, code: 'pics.quota', detail: `Your pictures would pass ${Math.round(cfg.quotaBytes / 1024 ** 3)} GB. Delete some first.` };
+ }
+
+ const prepared = await images.prepare(buf, { maxPixels: cfg.maxPixels });
+ if (!prepared.ok) return { ok: false, status: 422, code: prepared.code, detail: prepared.detail };
+ const hit = await blocked(prepared);
+ if (hit) {
+ (log.warn || log.log)(`[OpenVibe.Pics] refused an upload matching a takedown (${hit.reason})`);
+ return { ok: false, status: 422, code: 'pics.blocked', detail: 'This picture was taken down from OpenVibe.Pics and cannot be uploaded again.' };
+ }
+ await store.countUpload(s, uploader, day());
+
+ const id = s.newId('pic');
+ const base = cleanLine(title, LIMITS.title) || cleanLine(String(file.name || '').replace(/\.[A-Za-z0-9]{1,5}$/, ''), LIMITS.title);
+ const main = await putObject(prepared.image.buffer, { mime: prepared.image.mime, name: `${id}.${prepared.image.ext}`, owner: who && who.requester, anonymous });
+ if (!main.ok) return { ok: false, status: 503, code: 'pics.storage', detail: 'The picture could not be stored just now. Try again in a minute.' };
+ const thumb = await putObject(prepared.thumb.buffer, { mime: 'image/webp', name: `${id}-thumb.webp`, owner: who && who.requester, anonymous });
+ const deleteKey = anonymous ? crypto.randomBytes(18).toString('base64url') : null;
+ const row = await store.insertImage(s, {
+ id, slug: slug(), owner: anonymous ? null : who.requester,
+ delete_key_hash: deleteKey ? images.sha256(Buffer.from(deleteKey)) : null,
+ title: base, description: cleanText(description, LIMITS.description),
+ object_id: main.id, thumb_object_id: thumb.ok ? thumb.id : null,
+ mime_type: prepared.image.mime, ext: prepared.image.ext, width: prepared.image.width, height: prepared.image.height,
+ bytes: prepared.image.buffer.length, animated: prepared.image.animated,
+ sha256: prepared.sha256, source_sha256: prepared.sourceSha256, dhash: prepared.dhash, created_at: s.iso(),
+ });
+ return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}) };
+ }
+
+ async function edit(row, { title, description }) {
+ return store.updateImage(s, row.id, {
+ ...(title !== undefined ? { title: cleanLine(title, LIMITS.title) } : {}),
+ ...(description !== undefined ? { description: cleanText(description, LIMITS.description) } : {}),
+ });
+ }
+
+ const hide = (row) => store.updateImage(s, row.id, { state: 'hidden' });
+
+ async function removeObjects(row) {
+ for (const oid of [row.object_id, row.thumb_object_id].filter(Boolean)) {
+ const r = await media.remove(oid);
+ if (!r.ok && r.status !== 404) (log.warn || log.log)(`[OpenVibe.Pics] Media kept ${oid}: ${r.code || r.status}`);
+ }
+ }
+
+ /** The uploader deletes it: the bytes in Media and the row (its reports and album places go with it). */
+ async function remove(row) {
+ await removeObjects(row);
+ await store.deleteImage(s, row.id);
+ }
+
+ /** A signed-out upload's delete key matches? (Compared as hashes, in constant time.) */
+ function deleteKeyMatches(row, key) {
+ if (!row || !row.delete_key_hash || typeof key !== 'string' || !key) return false;
+ const a = Buffer.from(images.sha256(Buffer.from(key)));
+ const b = Buffer.from(row.delete_key_hash);
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+ }
+
+ async function report(row, { reporter, reason, note }) {
+ const r = REASONS.includes(reason) ? reason : null;
+ if (!r) return { ok: false, status: 422, code: 'pics.report_reason', detail: `reason: one of ${REASONS.join(', ')}` };
+ const out = await store.addReport(s, row.id, { id: s.newId('prp'), reporter, reason: r, note: cleanText(note, LIMITS.note) });
+ let hidden = false;
+ if (row.state === 'active' && (r === 'child_safety' || out.count >= cfg.reportsToHide)) {
+ await hide(row);
+ hidden = true;
+ }
+ if (r === 'child_safety' && out.added) (log.warn || log.log)(`[OpenVibe.Pics] child-safety report on ${row.id}: hidden until staff review`);
+ return { ok: true, added: out.added, count: out.count, hidden };
+ }
+
+ /** Staff: restore (active), hide, or take down (removed, bytes deleted, hashes blocklisted). */
+ async function moderate(row, action, { by, reason = 'staff' } = {}) {
+ if (action === 'restore') {
+ if (row.state === 'removed') return { ok: false, row, detail: 'A picture that was taken down stays down; its bytes are gone.' };
+ return { ok: true, row: await store.updateImage(s, row.id, { state: 'active' }) };
+ }
+ if (action === 'hide') return { ok: true, row: row.state === 'active' ? await hide(row) : row };
+ if (action !== 'takedown') return { ok: false, row, detail: 'action: restore, hide or takedown' };
+ for (const [kind, value] of [['sha256', row.sha256], ['sha256', row.source_sha256], ['dhash', row.dhash]]) {
+ if (kind === 'dhash' && !distinctive(value)) continue;
+ await store.block(s, { kind, value, reason: cleanLine(reason, 60) || 'staff', imageId: row.id, by });
+ }
+ await removeObjects(row);
+ return { ok: true, row: await store.updateImage(s, row.id, { state: 'removed' }) };
+ }
+
+ /** Count a view for the retention clock, at most once an hour per picture. */
+ async function viewed(row) {
+ const last = Date.parse(row.last_viewed_at || 0);
+ if (Number.isFinite(last) && s.now() - last < 3_600_000) return;
+ await store.updateImage(s, row.id, { last_viewed_at: s.iso() });
+ }
+
+ async function sweep() {
+ const before = new Date(s.now() - cfg.anonymousIdleDays * 86_400_000).toISOString();
+ const rows = await store.idleAnonymous(s, before);
+ for (const row of rows) await remove(row);
+ return rows.length;
+ }
+
+ // ── Albums (account-only) ───────────────────────────────
+ async function createAlbum(owner, { title, description = '' }) {
+ const t = cleanLine(title, LIMITS.albumTitle);
+ if (!t) return { ok: false, status: 422, code: 'pics.album_title', detail: 'Give the album a name.' };
+ if (await store.albumCount(s, owner) >= cfg.albumsMax) return { ok: false, status: 409, code: 'pics.album_limit', detail: `You have ${cfg.albumsMax} albums, the most you can.` };
+ const row = await store.insertAlbum(s, { id: s.newId('alb'), slug: slug(), owner, title: t, description: cleanText(description, LIMITS.description), created_at: s.iso() });
+ return { ok: true, album: row };
+ }
+
+ async function addToAlbum(album, image) {
+ if (await store.albumImageCount(s, album.id) >= cfg.albumImagesMax) return { ok: false, status: 409, code: 'pics.album_full', detail: `An album holds ${cfg.albumImagesMax} pictures.` };
+ await store.addToAlbum(s, album.id, image.id);
+ return { ok: true };
+ }
+
+ /** The public links of a picture: its page, the direct file, the thumbnail and the embed snippets. */
+ function links(row) {
+ const base = cfg.url;
+ const page = `${base}/i/${row.slug}`;
+ const direct = `${base}/i/${row.slug}.${row.ext}`;
+ const alt = row.title || 'Picture on OpenVibe.Pics';
+ return {
+ page, direct, thumb: `${base}/i/${row.slug}/thumb.webp`,
+ markdown: `![${alt.replace(/[[\]]/g, '')}](${direct})`,
+ html: `<a href="${page}"><img src="${direct}" alt="${alt.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))}"></a>`,
+ bbcode: `[url=${page}][img]${direct}[/img][/url]`,
+ };
+ }
+
+ const toWire = (row, { owner = false } = {}) => ({
+ id: row.id, slug: row.slug, title: row.title, description: row.description, mime_type: row.mime_type, ext: row.ext,
+ width: Number(row.width), height: Number(row.height), bytes: Number(row.bytes), animated: Boolean(row.animated),
+ state: row.state, created_at: row.created_at, links: links(row),
+ ...(owner ? { report_count: Number(row.report_count) } : {}),
+ });
+
+ return {
+ cfg, REASONS, REASON_TEXT, LIMITS, SLUG_RE, isSlug: (v) => SLUG_RE.test(String(v || '')),
+ addressId, canSee, isOwner, upload, edit, remove, deleteKeyMatches, report, moderate, viewed, sweep,
+ createAlbum, addToAlbum, links, toWire, isStaff,
+ };
+}
+
+module.exports = { createPics, REASONS, SLUG_RE, DHASH_DISTANCE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
+'use strict';
+
+/**
+ * OpenVibe.Pics' rows (migrations/0006_pics.sql). Every function takes the store (server/db.js createStore) first.
+ * Pictures and albums are read by slug from the pages and by id inside; a picture that is not `active` is shown to
+ * nobody but its uploader and staff (server/pics/service.js decides who).
+ */
+
+const IMAGE_COLS = ['title', 'description', 'state', 'report_count', 'last_viewed_at', 'thumb_object_id'];
+
+const imageBySlug = (s, slug) => s.db.maybe('SELECT * FROM pics_images WHERE slug = $1', [slug]);
+const imageById = (s, id) => s.db.maybe('SELECT * FROM pics_images WHERE id = $1', [id]);
+
+async function insertImage(s, row) {
+ await s.db.query(
+ `INSERT INTO pics_images (id, slug, owner, delete_key_hash, title, description, object_id, thumb_object_id, mime_type, ext,
+ width, height, bytes, animated, sha256, source_sha256, dhash, state, report_count, created_at, updated_at, last_viewed_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'active', 0, $18, $18, $18)`,
+ [row.id, row.slug, row.owner, row.delete_key_hash, row.title, row.description, row.object_id, row.thumb_object_id, row.mime_type,
+ row.ext, row.width, row.height, row.bytes, row.animated, row.sha256, row.source_sha256, row.dhash, row.created_at]);
+ return imageById(s, row.id);
+}
+
+async function updateImage(s, id, patch) {
+ const cols = IMAGE_COLS.filter((c) => patch[c] !== undefined);
+ if (!cols.length) return imageById(s, id);
+ await s.db.query(`UPDATE pics_images SET ${cols.map((c, i) => `${c} = $${i + 3}`).join(', ')}, updated_at = $2 WHERE id = $1`, [id, s.iso(), ...cols.map((c) => patch[c])]);
+ return imageById(s, id);
+}
+
+const deleteImage = (s, id) => s.db.exec('DELETE FROM pics_images WHERE id = $1', [id]);
+
+/** A person's pictures, newest first, a page at a time by id cursor. */
+async function imagesOf(s, owner, { limit = 48, before = null } = {}) {
+ const rows = await s.db.many(
+ `SELECT * FROM pics_images WHERE owner = $1 AND state <> 'removed' ${before ? 'AND id < $3' : ''} ORDER BY id DESC LIMIT $2`,
+ before ? [owner, limit + 1, before] : [owner, limit + 1]);
+ return { rows: rows.slice(0, limit), next: rows.length > limit ? rows[limit - 1].id : null };
+}
+
+const usageOf = async (s, owner) => {
+ const r = await s.db.maybe("SELECT count(*)::int AS n, coalesce(sum(bytes), 0)::bigint AS bytes FROM pics_images WHERE owner = $1 AND state <> 'removed'", [owner]);
+ return { images: Number(r ? r.n : 0), bytes: Number(r ? r.bytes : 0) };
+};
+
+/** Count one upload for an uploader today; answers the count after it. */
+async function countUpload(s, uploader, day) {
+ return Number(await s.db.value(
+ `INSERT INTO pics_usage (uploader, day, uploads) VALUES ($1, $2, 1)
+ ON CONFLICT (uploader, day) DO UPDATE SET uploads = pics_usage.uploads + 1 RETURNING uploads`, [uploader, day]));
+}
+const uploadsToday = async (s, uploader, day) => Number(await s.db.value('SELECT coalesce(max(uploads), 0) FROM pics_usage WHERE uploader = $1 AND day = $2', [uploader, day]));
+
+// ── Blocklist ───────────────────────────────────────────────
+const blockedSha = (s, values) => s.db.maybe("SELECT * FROM pics_blocklist WHERE kind = 'sha256' AND value = ANY($1::text[])", [values]);
+const blockedDhashes = (s) => s.db.many("SELECT value, reason FROM pics_blocklist WHERE kind = 'dhash'");
+async function block(s, { kind, value, reason, imageId, by }) {
+ await s.db.query(
+ `INSERT INTO pics_blocklist (kind, value, reason, image_id, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6)
+ ON CONFLICT (kind, value) DO NOTHING`, [kind, value, reason, imageId, by, s.iso()]);
+}
+
+// ── Reports ─────────────────────────────────────────────────
+/** One report per reporter per picture: { added, count } — `added` false when this reporter had already reported it. */
+async function addReport(s, imageId, { id, reporter, reason, note }) {
+ const ins = await s.db.maybe(
+ `INSERT INTO pics_reports (id, image_id, reporter, reason, note, created_at) VALUES ($1, $2, $3, $4, $5, $6)
+ ON CONFLICT (image_id, reporter) DO NOTHING RETURNING id`, [id, imageId, reporter, reason, note, s.iso()]);
+ const count = Number(await s.db.value('SELECT count(*)::int FROM pics_reports WHERE image_id = $1', [imageId]));
+ await s.db.query('UPDATE pics_images SET report_count = $2 WHERE id = $1', [imageId, count]);
+ return { added: Boolean(ins), count };
+}
+const reportsFor = (s, imageId) => s.db.many('SELECT * FROM pics_reports WHERE image_id = $1 ORDER BY created_at DESC', [imageId]);
+
+/** The staff queue: hidden pictures first (waiting on a decision), then reported active ones. */
+const staffQueue = (s, { state = 'hidden', limit = 60 } = {}) => s.db.many(
+ state === 'reported'
+ ? "SELECT * FROM pics_images WHERE state = 'active' AND report_count > 0 ORDER BY report_count DESC, id DESC LIMIT $1"
+ : 'SELECT * FROM pics_images WHERE state = $2 ORDER BY id DESC LIMIT $1',
+ state === 'reported' ? [limit] : [limit, state]);
+const staffCounts = async (s) => {
+ const rows = await s.db.many("SELECT state, count(*)::int AS n FROM pics_images WHERE state <> 'active' OR report_count > 0 GROUP BY state");
+ const reported = Number(await s.db.value("SELECT count(*)::int FROM pics_images WHERE state = 'active' AND report_count > 0"));
+ return { hidden: Number((rows.find((r) => r.state === 'hidden') || {}).n || 0), removed: Number((rows.find((r) => r.state === 'removed') || {}).n || 0), reported };
+};
+
+/** Signed-out pictures nobody has looked at since `beforeIso`: the retention sweep's work. */
+const idleAnonymous = (s, beforeIso, limit = 200) => s.db.many(
+ "SELECT * FROM pics_images WHERE owner IS NULL AND state <> 'removed' AND coalesce(last_viewed_at, created_at) < $1 ORDER BY id LIMIT $2", [beforeIso, limit]);
+
+// ── Albums ──────────────────────────────────────────────────
+const albumBySlug = (s, slug) => s.db.maybe('SELECT * FROM pics_albums WHERE slug = $1', [slug]);
+const albumsOf = (s, owner) => s.db.many(
+ `SELECT a.*, (SELECT count(*)::int FROM pics_album_images ai WHERE ai.album_id = a.id) AS image_count,
+ (SELECT i.slug FROM pics_album_images ai JOIN pics_images i ON i.id = ai.image_id WHERE ai.album_id = a.id AND i.state = 'active' ORDER BY ai.position LIMIT 1) AS cover_slug
+ FROM pics_albums a WHERE a.owner = $1 ORDER BY a.id DESC`, [owner]);
+const albumCount = async (s, owner) => Number(await s.db.value('SELECT count(*)::int FROM pics_albums WHERE owner = $1', [owner]));
+
+async function insertAlbum(s, row) {
+ await s.db.query('INSERT INTO pics_albums (id, slug, owner, title, description, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $6)',
+ [row.id, row.slug, row.owner, row.title, row.description, row.created_at]);
+ return albumBySlug(s, row.slug);
+}
+async function updateAlbum(s, id, { title, description }) {
+ await s.db.query('UPDATE pics_albums SET title = coalesce($2, title), description = coalesce($3, description), updated_at = $4 WHERE id = $1', [id, title, description, s.iso()]);
+}
+const deleteAlbum = (s, id) => s.db.exec('DELETE FROM pics_albums WHERE id = $1', [id]);
+
+/** The album's pictures in order; `visibleOnly` leaves out the hidden and removed ones (everyone but the owner). */
+const albumImages = (s, albumId, { visibleOnly = true } = {}) => s.db.many(
+ `SELECT i.* FROM pics_album_images ai JOIN pics_images i ON i.id = ai.image_id
+ WHERE ai.album_id = $1 ${visibleOnly ? "AND i.state = 'active'" : "AND i.state <> 'removed'"} ORDER BY ai.position, ai.added_at`, [albumId]);
+const albumImageCount = async (s, albumId) => Number(await s.db.value('SELECT count(*)::int FROM pics_album_images WHERE album_id = $1', [albumId]));
+async function addToAlbum(s, albumId, imageId) {
+ const pos = Number(await s.db.value('SELECT coalesce(max(position), 0) + 1 FROM pics_album_images WHERE album_id = $1', [albumId]));
+ await s.db.query('INSERT INTO pics_album_images (album_id, image_id, position, added_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING', [albumId, imageId, pos, s.iso()]);
+ await s.db.query('UPDATE pics_albums SET updated_at = $2 WHERE id = $1', [albumId, s.iso()]);
+}
+const removeFromAlbum = (s, albumId, imageId) => s.db.exec('DELETE FROM pics_album_images WHERE album_id = $1 AND image_id = $2', [albumId, imageId]);
+const albumsHolding = (s, imageId) => s.db.many('SELECT a.* FROM pics_album_images ai JOIN pics_albums a ON a.id = ai.album_id WHERE ai.image_id = $1 ORDER BY a.title', [imageId]);
+
+module.exports = {
+ imageBySlug, imageById, insertImage, updateImage, deleteImage, imagesOf, usageOf, countUpload, uploadsToday,
+ blockedSha, blockedDhashes, block, addReport, reportsFor, staffQueue, staffCounts, idleAnonymous,
+ albumBySlug, albumsOf, albumCount, insertAlbum, updateAlbum, deleteAlbum, albumImages, albumImageCount, addToAlbum, removeFromAlbum, albumsHolding,
+};
@@ -25,6 +25,7 @@ const READ = 'media-hub.api.read';
@@ -25,6 +25,7 @@ const READ = 'media-hub.api.read';
'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
'media-hub.share.create',
'media-hub.share.create',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
+ 'pics.album.create', 'pics.image.upload', 'pics.image.write', 'pics.report.create',
]);
]);
for (const [name, own] of Object.entries(BUDGETS)) {
for (const [name, own] of Object.entries(BUDGETS)) {
assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);
assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);
@@ -141,8 +141,9 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -141,8 +141,9 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
if (!body) return problem(res, 400, 'media.object.invalid', 'body is not JSON');
if (!body) return problem(res, 400, 'media.object.invalid', 'body is not JSON');
const size = Number(body.size_bytes);
const size = Number(body.size_bytes);
if (!Number.isInteger(size) || size < 1) return problem(res, 400, 'media.object.invalid', 'size_bytes must be a positive integer');
if (!Number.isInteger(size) || size < 1) return problem(res, 400, 'media.object.invalid', 'size_bytes must be a positive integer');
- const subject = req.headers['x-ov-subject'];
- if (!subject || !ids.isSubjectId('user', subject)) return problem(res, 400, 'media.object.invalid', 'X-OV-Subject must be a user subject');
+ // No X-OV-Subject: the tenant's own object (Media's subjectFrom answers null); a malformed one is refused.
+ const subject = req.headers['x-ov-subject'] || null;
+ if (subject !== null && !ids.isSubjectId('user', subject)) return problem(res, 400, 'media.object.invalid', 'X-OV-Subject must be a user subject');
const wantParts = body.multipart === true;
const wantParts = body.multipart === true;
if (!wantParts && size > maxSingle) return problem(res, 413, 'media.object.too_large', 'Single-part uploads are limited; send multipart: true');
if (!wantParts && size > maxSingle) return problem(res, 413, 'media.object.too_large', 'Single-part uploads are limited; send multipart: true');
seq += 1;
seq += 1;
@@ -173,7 +174,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -173,7 +174,7 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const sub = tail.slice(1);
const sub = tail.slice(1);
// PUT /objects/:id/content
// PUT /objects/:id/content
- if (!sub.length && req.method === 'PUT') {
+ if (sub.length === 1 && sub[0] === 'content' && req.method === 'PUT') {
if (obj.lifecycle_status !== 'uploading') return problem(res, 409, 'media.object.not_uploading', `Object is ${obj.lifecycle_status}`);
if (obj.lifecycle_status !== 'uploading') return problem(res, 409, 'media.object.not_uploading', `Object is ${obj.lifecycle_status}`);
if (raw.length !== Number(obj.size_bytes)) return problem(res, 400, 'media.object.size_mismatch', `Declared ${obj.size_bytes} bytes, received ${raw.length}`);
if (raw.length !== Number(obj.size_bytes)) return problem(res, 400, 'media.object.size_mismatch', `Declared ${obj.size_bytes} bytes, received ${raw.length}`);
obj.bytes = raw;
obj.bytes = raw;
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
-// deploy/nginx/openvibe.download.conf: openvibe.pics is still parked (302 to openvibe.network).
-// openvibe.video is now OpenVibe.Video, served by the same app on the same port, chosen by Host
-// header: its apex HTTPS block proxies to 127.0.0.1:4990 exactly like openvibe.download, and its
-// HTTP and www blocks redirect to the apex. Each host keeps its own certificate.
+// deploy/nginx/openvibe.download.conf: openvibe.video (OpenVibe.Video) and openvibe.pics (OpenVibe.Pics, 2026-10-15) are
+// served by the same app on the same port as openvibe.download, chosen by Host header: each apex HTTPS block proxies to
+// 127.0.0.1:4990, and each host's HTTP and www blocks redirect to its apex. Nothing is parked any more. Each host keeps
+// its own certificate. Pics takes a picture (21 MB) only on its two upload paths; everything else stays at 128 KB.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -10,36 +10,35 @@ const path = require('path');
@@ -10,36 +10,35 @@ const path = require('path');
const conf = fs.readFileSync(path.join(__dirname, '..', ...'deploy/nginx/openvibe.download.conf'.split('/')), 'utf8');
const conf = fs.readFileSync(path.join(__dirname, '..', ...'deploy/nginx/openvibe.download.conf'.split('/')), 'utf8');
const blocks = [...conf.matchAll(/(?:^|\n)server \{([\s\S]*?)\n\}/g)].map((m) => m[1]);
const blocks = [...conf.matchAll(/(?:^|\n)server \{([\s\S]*?)\n\}/g)].map((m) => m[1]);
const named = (host) => blocks.filter((b) => new RegExp(`server_name [^;]*\\b${host.replace(/\./g, '\\.')}\\b`).test(b));
const named = (host) => blocks.filter((b) => new RegExp(`server_name [^;]*\\b${host.replace(/\./g, '\\.')}\\b`).test(b));
+const esc = (host) => host.replace(/\./g, '\\.');
-// openvibe.pics: parked. Its HTTP block and apex/www HTTPS blocks all answer 302 to the network.
-{
- const parked = named('openvibe.pics');
- assert.ok(parked.length >= 3, 'openvibe.pics: the HTTP block and the apex and www HTTPS blocks are there');
- for (const b of parked) {
- assert.match(b, /return 302 https:\/\/openvibe\.network\/;/, 'openvibe.pics: redirects to openvibe.network');
- assert.doesNotMatch(b, /proxy_pass/, 'openvibe.pics: proxies nothing');
+for (const host of ['openvibe.video', 'openvibe.pics']) {
+ const all = named(host);
+ assert.ok(all.length >= 3, `${host}: the HTTP block and the apex and www HTTPS blocks are there`);
+ const apex = all.find((b) => /listen 443 ssl;/.test(b) && new RegExp(`server_name ${esc(host)};`).test(b));
+ const www = all.find((b) => /listen 443 ssl;/.test(b) && new RegExp(`server_name www\\.${esc(host)};`).test(b));
+ const plain = all.find((b) => /listen 80;/.test(b));
+ assert.ok(apex && www && plain, `${host}: apex, www and HTTP blocks are there`);
+ assert.match(apex, /proxy_pass http:\/\/127\.0\.0\.1:4990;/, `${host} apex proxies to the app`);
+ assert.match(www, new RegExp(`return 301 https://${esc(host)}\\$request_uri;`), `${host} www redirects to apex`);
+ assert.match(plain, new RegExp(`return 301 https://${esc(host)}\\$request_uri;`), `${host} HTTP redirects to apex`);
+ assert.doesNotMatch(apex, /return 302 https:\/\/openvibe\.network\/;/, `${host} apex is not parked`);
+ assert.match(apex, /location ~\* \^\/internal\(\/\|\$\) \{ return 404; \}/, `${host}: /internal is not proxied`);
+ for (const b of all.filter((x) => /listen 443 ssl;/.test(x))) {
+ assert.match(b, new RegExp(`ssl_certificate\\s+/etc/letsencrypt/live/${esc(host)}/fullchain\\.pem;`), `${host}: its own certificate`);
}
}
}
}
-// openvibe.video: no longer parked. Its apex HTTPS block proxies to the app; www and HTTP redirect to apex.
+// openvibe.pics: a picture only where one is uploaded.
{
{
- const video = named('openvibe.video');
- assert.ok(video.length >= 3, 'openvibe.video: the HTTP block and the apex and www HTTPS blocks are there');
- const apex = video.find((b) => /listen 443 ssl;/.test(b) && /server_name openvibe\.video;/.test(b));
- const www = video.find((b) => /listen 443 ssl;/.test(b) && /server_name www\.openvibe\.video;/.test(b));
- const http = video.find((b) => /listen 80;/.test(b));
- assert.ok(apex && www && http, 'openvibe.video: apex, www and HTTP blocks are there');
- assert.match(apex, /proxy_pass http:\/\/127\.0\.0\.1:4990;/, 'openvibe.video apex proxies to the app');
- assert.match(www, /return 301 https:\/\/openvibe\.video\$request_uri;/, 'openvibe.video www redirects to apex');
- assert.match(http, /return 301 https:\/\/openvibe\.video\$request_uri;/, 'openvibe.video HTTP redirects to apex');
- assert.doesNotMatch(apex, /return 302 https:\/\/openvibe\.network\/;/, 'openvibe.video apex is no longer parked');
-}
-
-// Each host uses its own certificate, on both its apex and its www block.
-for (const host of ['openvibe.pics', 'openvibe.video']) {
- for (const b of named(host).filter((x) => /listen 443 ssl;/.test(x))) {
- assert.match(b, new RegExp(`ssl_certificate\\s+/etc/letsencrypt/live/${host.replace(/\./g, '\\.')}/fullchain\\.pem;`), `${host}: its own certificate`);
+ const apex = named('openvibe.pics').find((b) => /server_name openvibe\.pics;/.test(b));
+ assert.match(apex, /\n client_max_body_size 128k;/, 'pics: 128 KB by default');
+ for (const loc of ['= /api/v1/images', '= /upload']) {
+ const m = new RegExp(`location ${loc.replace(/[/.]/g, '\\$&')} \\{([\\s\\S]*?)\\n \\}`).exec(apex);
+ assert.ok(m, `pics: location ${loc}`);
+ assert.match(m[1], /client_max_body_size 21m;/, `pics: ${loc} takes a 20 MB picture`);
+ assert.match(m[1], /limit_req zone=media-hub_writes/, `pics: ${loc} is rate-limited`);
}
}
}
}
-console.log('nginx: pics parked (302 openvibe.network), video proxies to 127.0.0.1:4990, own certificates');
+console.log('nginx: video and pics proxy to 127.0.0.1:4990 with their own certificates; pics takes pictures only on its upload paths');
new file mode 100644
new file mode 100644
@@ -0,0 +1,240 @@
@@ -0,0 +1,240 @@
+'use strict';
+/**
+ * OpenVibe.Pics (server/pics) on Host: openvibe.pics. A person uploads a picture (the API or the plain form); it is
+ * re-encoded with its metadata gone, stored as private Media objects and served from /i/<slug>.<ext>; its page has
+ * the links to share and embed. Others cannot change it; reports hide it (a child-safety report at once); a staff
+ * takedown deletes it and blocks the same picture or a near-duplicate from coming back. Albums. Signed-out uploads
+ * only when MEDIAHUB_PICS_ANONYMOUS=1, with a delete key and the idle sweep. The drive and Pics never answer on each
+ * other's host.
+ */
+const assert = require('assert');
+const http = require('http');
+const sharp = require('sharp');
+const { boot, check, done } = require('./helpers/boot');
+
+/**
+ * t.get with a Host header (Node's fetch drops one, so this is http.request): the same options — as, json, form, body,
+ * headers, method — and the same answer shape.
+ */
+function hostClient(t) {
+ return (p, o = {}) => new Promise((resolve, reject) => {
+ const headers = { Host: 'openvibe.pics', ...(o.headers || {}) };
+ if (o.as) headers.cookie = t.signIn(o.as);
+ let body = o.body;
+ if (o.json !== undefined) { body = Buffer.from(JSON.stringify(o.json)); headers['content-type'] = 'application/json'; }
+ if (o.form !== undefined) { body = Buffer.from(new URLSearchParams(o.form).toString()); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ if (body != null) headers['content-length'] = String(Buffer.byteLength(body));
+ const u = new URL(t.base + p);
+ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: o.method || (body != null ? 'POST' : 'GET'), headers }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => {
+ const buf = Buffer.concat(chunks);
+ const text = buf.toString('utf8');
+ resolve({ status: res.statusCode, headers: { get: (k) => res.headers[String(k).toLowerCase()] || null }, text, buffer: buf, json() { return JSON.parse(text); } });
+ });
+ });
+ req.on('error', reject);
+ if (body != null) req.write(body);
+ req.end();
+ });
+}
+
+const PICS = { Host: 'openvibe.pics' };
+const SAME = { ...PICS, 'sec-fetch-site': 'same-origin' };
+
+const photo = (bg = '#335', w = 1200, h = 800) => sharp({ create: { width: w, height: h, channels: 3, background: bg } })
+ .composite([{ input: Buffer.from(`<svg width="${w}" height="${h}"><rect width="${w}" height="${h / 2}" fill="#f80"/><circle cx="${w * 0.66}" cy="${h * 0.62}" r="${h / 4}" fill="#eee"/><rect x="${w / 12}" y="${h * 0.56}" width="${w / 4}" height="${h / 3}" fill="#2a2"/></svg>`) }]);
+
+function multipart(fields, file) {
+ const b = `----ovp${Date.now()}`;
+ const parts = Object.entries(fields).map(([k, v]) => Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
+ parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="file"; filename="${file.name}"\r\nContent-Type: ${file.type}\r\n\r\n`), file.buffer, Buffer.from(`\r\n--${b}--\r\n`));
+ return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${b}` };
+}
+
+(async () => {
+ const jpegWithSecrets = await photo().withMetadata({ orientation: 6, exif: { IFD0: { Make: 'SecretCam', Copyright: 'someone' }, IFD3: { GPSLatitudeRef: 'N', GPSLatitude: '40/1 45/1 0/1' } } }).jpeg({ quality: 92 }).toBuffer();
+ const t0 = await boot();
+ const t = { ...t0, get: hostClient(t0) };
+ try {
+ const kim = t.network.addUser('kim');
+ const lee = t.network.addUser('lee');
+ const ana = t.network.addUser('ana');
+ const boss = t.network.addUser('boss', { role: 'admin' });
+ const up = (as, buf, { type = 'image/jpeg', title = '' } = {}) => t.get('/api/v1/images', { as, method: 'POST', body: buf, headers: { ...SAME, 'content-type': type, ...(title ? { 'x-title': encodeURIComponent(title) } : {}) } });
+ let slug;
+ let direct;
+
+ await check('the hosts stay apart: Pics on openvibe.pics, the drive on openvibe.download', async () => {
+ const home = await t.get('/', { headers: PICS });
+ assert.strictEqual(home.status, 200);
+ assert.ok(home.text.includes('Share pictures.') && home.text.includes('Sign in to upload'));
+ assert.strictEqual((await t.get('/files', { headers: PICS, as: kim })).status, 404, 'no drive pages on the pics host');
+ assert.ok((await t.get('/', { headers: { Host: 'openvibe.download' } })).text.indexOf('Share pictures.') === -1, 'the drive host is not Pics');
+ assert.strictEqual((await t.get('/robots.txt', { headers: PICS })).text.includes('Disallow: /i/'), true, 'pictures are not crawled');
+ });
+
+ await check('signed out, uploads are refused until MEDIAHUB_PICS_ANONYMOUS is on', async () => {
+ const r = await t.get('/api/v1/images', { method: 'POST', body: jpegWithSecrets, headers: { ...SAME, 'content-type': 'image/jpeg' } });
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.json().code, 'pics.sign_in_required');
+ });
+
+ await check('an upload is re-encoded without its EXIF (GPS, camera), turned upright, and stored private in Media', async () => {
+ const r = await up(kim, jpegWithSecrets, { title: 'The coast' });
+ assert.strictEqual(r.status, 201, r.text);
+ const b = r.json();
+ slug = b.slug;
+ direct = `/i/${slug}.jpg`;
+ assert.match(slug, /^[2-9a-km-zA-HJ-NP-Z]{8}$/);
+ assert.deepStrictEqual([b.title, b.ext, b.width, b.height, b.state], ['The coast', 'jpg', 800, 1200, 'active'], 'orientation 6 is applied: 1200×800 becomes 800×1200');
+ assert.strictEqual(b.links.direct, `https://openvibe.pics${direct}`);
+ assert.ok(b.links.bbcode.includes(`[img]https://openvibe.pics${direct}[/img]`));
+ const objects = [...t.media.objects.values()];
+ assert.ok(objects.length >= 2 && objects.every((o) => o.visibility === 'private'), 'private Media objects: only this service serves them');
+
+ const file = await t.get(direct, { headers: PICS });
+ assert.strictEqual(file.status, 200);
+ assert.strictEqual(file.headers.get('content-type'), 'image/jpeg');
+ assert.strictEqual(file.headers.get('x-content-type-options'), 'nosniff');
+ assert.match(file.headers.get('content-security-policy'), /sandbox/);
+ assert.strictEqual(file.headers.get('cache-control'), 'public, max-age=600, s-maxage=600');
+ const meta = await sharp(file.buffer).metadata();
+ assert.strictEqual(meta.exif, undefined, 'no EXIF block');
+ assert.ok(!file.buffer.includes(Buffer.from('SecretCam')), 'the camera name is gone');
+ const thumb = await t.get(`/i/${slug}/thumb.webp`, { headers: PICS });
+ assert.strictEqual(thumb.status, 200);
+ assert.strictEqual((await sharp(thumb.buffer).metadata()).format, 'webp');
+ assert.strictEqual((await t.get(`/i/${slug}.png`, { headers: PICS })).status, 404, 'only its own extension');
+ });
+
+ await check('what is not a JPEG, PNG, GIF, WebP or AVIF picture is refused', async () => {
+ assert.strictEqual((await up(kim, Buffer.from('<?php system($_GET[1]); ?>'.repeat(20)))).json().code, 'pics.not_an_image');
+ assert.strictEqual((await up(kim, Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9"><script>alert(1)</script></svg>'), { type: 'image/svg+xml' })).json().code, 'pics.unsupported');
+ assert.strictEqual((await up(kim, jpegWithSecrets.subarray(0, 4000))).json().code, 'pics.not_an_image', 'a truncated file');
+ const notImage = await t.get('/api/v1/images', { as: kim, method: 'POST', body: Buffer.from('hello'), headers: { ...SAME, 'content-type': 'text/plain' } });
+ assert.strictEqual(notImage.status, 415);
+ });
+
+ await check('the picture page: the links to share and embed, noindex, and the share image', async () => {
+ const page = await t.get(`/i/${slug}`, { headers: PICS });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('The coast'));
+ assert.ok(page.text.includes(`value="https://openvibe.pics${direct}"`));
+ assert.ok(page.text.includes('noindex'));
+ assert.ok(page.text.includes(`content="https://openvibe.pics${direct}"`), 'og:image is the picture');
+ assert.ok(page.text.includes('Report this picture'));
+ assert.ok(!page.text.includes('Delete this picture'), 'no owner tools for a visitor');
+ assert.ok((await t.get(`/i/${slug}`, { headers: PICS, as: kim })).text.includes('Delete this picture'));
+ });
+
+ await check('the plain form uploads without JavaScript; a write from another site is refused', async () => {
+ const png = await photo('#a3c').png().toBuffer();
+ const form = multipart({ title: 'From the form' }, { name: 'pink.png', type: 'image/png', buffer: png });
+ const r = await t.get('/upload', { as: kim, method: 'POST', body: form.body, headers: { ...SAME, 'content-type': form.type } });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
+ assert.match(r.headers.get('location'), /^\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\?new=1$/);
+ const cross = await t.get('/upload', { as: kim, method: 'POST', body: form.body, headers: { ...PICS, 'sec-fetch-site': 'cross-site', 'content-type': form.type } });
+ assert.strictEqual(cross.status, 403);
+ const crossApi = await t.get('/api/v1/images', { as: kim, method: 'POST', body: png, headers: { ...PICS, 'sec-fetch-site': 'cross-site', 'content-type': 'image/png' } });
+ assert.strictEqual(crossApi.json().code, 'request.cross_site');
+ });
+
+ await check('only the uploader changes or deletes a picture', async () => {
+ assert.strictEqual((await t.get(`/api/v1/images/${slug}`, { as: lee, method: 'PATCH', json: { title: 'mine now' }, headers: SAME })).status, 403);
+ assert.strictEqual((await t.get(`/api/v1/images/${slug}`, { as: lee, method: 'DELETE', headers: SAME })).status, 403);
+ const ok = await t.get(`/api/v1/images/${slug}`, { as: kim, method: 'PATCH', json: { title: 'The coast at dusk' }, headers: SAME });
+ assert.strictEqual(ok.json().title, 'The coast at dusk');
+ const extra = (await up(kim, await photo('#060').jpeg().toBuffer())).json();
+ const before = t.media.deleted.length;
+ assert.strictEqual((await t.get(`/api/v1/images/${extra.slug}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204);
+ assert.strictEqual(t.media.deleted.length - before, 2, 'the picture and its thumbnail are deleted in Media');
+ assert.strictEqual((await t.get(`/i/${extra.slug}.jpg`, { headers: PICS })).status, 404);
+ });
+
+ await check('three reports hide a picture: 404 for everyone but its uploader and staff', async () => {
+ const pic = (await up(kim, await photo('#909').jpeg().toBuffer(), { title: 'Reported' })).json();
+ const report = (as, extra = {}) => t.get(`/api/v1/images/${pic.slug}/reports`, { as, json: { reason: 'spam' }, headers: { ...SAME, ...extra } });
+ assert.strictEqual((await report(lee)).status, 201);
+ assert.strictEqual((await report(lee)).json().already, true, 'one report per person');
+ assert.strictEqual((await report(ana)).json().hidden, false);
+ const third = await report(null, { 'x-forwarded-for': '203.0.113.7' });
+ assert.strictEqual(third.json().hidden, true, 'a signed-out reporter counts by address');
+ assert.strictEqual((await t.get(`/i/${pic.slug}.jpg`, { headers: PICS })).status, 404);
+ assert.strictEqual((await t.get(`/i/${pic.slug}`, { headers: PICS })).status, 404);
+ const own = await t.get(`/i/${pic.slug}`, { headers: PICS, as: kim });
+ assert.ok(own.status === 200 && own.text.includes('hidden while staff look'));
+ const staffView = await t.get(`/i/${pic.slug}.jpg`, { headers: PICS, as: boss });
+ assert.strictEqual(staffView.status, 200, 'staff can see what they review');
+ assert.strictEqual(staffView.headers.get('cache-control'), 'private, no-store');
+ assert.ok((await t.get('/staff', { headers: PICS, as: boss })).text.includes('Reported'));
+ assert.strictEqual((await t.get('/staff', { headers: PICS, as: lee })).status, 404, 'the queue is staff only');
+ assert.strictEqual((await t.get(`/staff/i/${pic.slug}`, { headers: SAME, as: boss, form: { action: 'restore' } })).status, 303);
+ assert.strictEqual((await t.get(`/i/${pic.slug}.jpg`, { headers: PICS })).status, 200, 'restored');
+ });
+
+ await check('a child-safety report hides at once; a takedown deletes it and blocks the same picture and near-copies', async () => {
+ const original = await photo('#c22', 1000, 1000).jpeg({ quality: 90 }).toBuffer();
+ const pic = (await up(lee, original, { title: 'Bad' })).json();
+ const r = await t.get(`/api/v1/images/${pic.slug}/reports`, { as: ana, json: { reason: 'child_safety', note: 'please look' }, headers: SAME });
+ assert.strictEqual(r.json().hidden, true);
+ const before = t.media.deleted.length;
+ assert.strictEqual((await t.get(`/staff/i/${pic.slug}`, { headers: SAME, as: boss, form: { action: 'takedown', reason: 'csam' } })).status, 303);
+ assert.strictEqual(t.media.deleted.length - before, 2, 'the bytes are deleted');
+ assert.strictEqual((await t.get(`/i/${pic.slug}`, { headers: PICS })).status, 410);
+ assert.strictEqual((await t.get(`/i/${pic.slug}.jpg`, { headers: PICS })).status, 410);
+ assert.strictEqual((await up(kim, original)).json().code, 'pics.blocked', 'the same file');
+ const resized = await sharp(original).resize(700).webp({ quality: 70 }).toBuffer();
+ assert.strictEqual((await up(kim, resized, { type: 'image/webp' })).json().code, 'pics.blocked', 'a resized, re-compressed copy');
+ assert.strictEqual((await up(kim, await sharp(original).flop().jpeg().toBuffer())).status, 201, 'a different picture is fine');
+ });
+
+ await check('albums: made by a person, shown to anyone with the link, only the owner adds and removes', async () => {
+ const made = await t.get('/api/v1/albums', { as: kim, json: { title: 'Holiday' }, headers: SAME });
+ assert.strictEqual(made.status, 201);
+ const album = made.json();
+ assert.strictEqual((await t.get(`/api/v1/albums/${album.slug}/images`, { as: kim, json: { image: slug }, headers: SAME })).status, 204);
+ const leePic = (await up(lee, await photo('#077').jpeg().toBuffer())).json();
+ assert.strictEqual((await t.get(`/api/v1/albums/${album.slug}/images`, { as: kim, json: { image: leePic.slug }, headers: SAME })).status, 422, 'not your picture');
+ assert.strictEqual((await t.get(`/api/v1/albums/${album.slug}/images`, { as: lee, json: { image: leePic.slug }, headers: SAME })).status, 404, 'not your album');
+ const view = await t.get(`/a/${album.slug}`, { headers: PICS });
+ assert.ok(view.status === 200 && view.text.includes('Holiday') && view.text.includes(`/i/${slug}/thumb.webp`));
+ assert.strictEqual((await t.get(`/api/v1/albums/${album.slug}`, { as: kim, method: 'DELETE', headers: SAME })).status, 204);
+ assert.strictEqual((await t.get(`/i/${slug}`, { headers: PICS })).status, 200, 'the pictures stay');
+ });
+
+ await check('your pictures and the API list: newest first, with your usage', async () => {
+ const r = await t.get('/api/v1/me/images', { as: kim, headers: PICS });
+ assert.ok(r.json().images.length >= 2 && r.json().usage.images >= 2);
+ assert.strictEqual((await t.get('/mine', { headers: PICS })).status, 401);
+ assert.ok((await t.get('/mine', { headers: PICS, as: kim })).text.includes('The coast at dusk'));
+ });
+ } finally { await t0.close(); }
+
+ // ── Signed-out uploads, when the owner turns them on ──
+ let clock = Date.parse('2026-10-15T12:00:00Z');
+ const t20 = await boot({ env: { MEDIAHUB_PICS_ANONYMOUS: '1', MEDIAHUB_PICS_ANONYMOUS_PER_DAY: '2' }, now: () => clock });
+ const t2 = { ...t20, get: hostClient(t20) };
+ try {
+ await check('signed out (when on): a delete key shown once, kept as a hash, a daily limit, and the idle sweep', async () => {
+ const anon = (buf) => t2.get('/api/v1/images', { method: 'POST', body: buf, headers: { ...SAME, 'content-type': 'image/jpeg', 'x-forwarded-for': '198.51.100.4' } });
+ const r = await anon(await photo('#246').jpeg().toBuffer());
+ assert.strictEqual(r.status, 201, r.text);
+ const b = r.json();
+ assert.ok(b.delete_key && b.delete_url.includes(b.delete_key));
+ assert.ok(!(await t2.dbDump()).includes(b.delete_key), 'only the key\'s hash is stored');
+ assert.strictEqual((await t2.get(`/api/v1/images/${b.slug}`, { method: 'DELETE', headers: { ...SAME, 'x-delete-key': 'wrong' } })).status, 403);
+ const page = await t2.get(`/i/${b.slug}/delete?key=${b.delete_key}`, { headers: PICS });
+ assert.ok(page.status === 200 && page.text.includes('Delete it for good'), 'the delete link is a page; a GET never deletes');
+ assert.strictEqual((await t2.get(`/api/v1/images/${b.slug}`, { method: 'DELETE', headers: { ...SAME, 'x-delete-key': b.delete_key } })).status, 204);
+
+ const kept = (await anon(await photo('#642').jpeg().toBuffer())).json();
+ assert.strictEqual((await anon(await photo('#426').jpeg().toBuffer())).json().code, 'pics.daily_limit');
+ clock += 31 * 86_400_000;
+ assert.strictEqual(await t2.ctx.pics.sweep(), 1, 'a signed-out picture nobody viewed for 30 days goes');
+ assert.strictEqual((await t2.get(`/i/${kept.slug}`, { headers: PICS })).status, 404);
+ });
+ } finally { await t20.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });