openvibers/openvibe.mediahub Commits

MediaHub: the four audiences on pictures, albums and video uploads (sharing step 4) (#295)

OP OpenVibers committed 6 days ago · 1d0c628ee2 · 22 files, +679 −26

* MediaHub: the four audiences on pictures, albums and video uploads (sharing step 4)

Pictures, albums and video uploads get an openvibe-sdk/share row, Everyone by
default (a picture and an album have always been reachable by their link). The
Share button on a picture and an album opens the one sheet: Everyone, Anyone
with the link, Specific people, Only me. The share row decides the page, the
file, the thumbnail, the variants and the API; an album's row decides the
album. A people picture answers a stranger with the gate, and its bytes are
never served. A video upload's row sits beside its old visibility column.

Migrations 0020 (the SDK's tables) and 0021 (the backfill: pictures and albums
Everyone, each video upload by its column).

* Account export and deletion cover the share rows; keep drafts and members pages closed

* Sharing: a row written before the model falls back to the old column; the second account-data guard too

* The share gate draws from markup alone (openvibe-shared 4.123.0), with a headless-Chrome check; MediaHub's cache and column follow the audience

* Merge remote-tracking branch 'origin/main' into feat/share-media-visibility

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

Changes

ddiff --git a/README.md b/README.md
iindex 878396c..fd909b6 100644
---- a/README.md
++++ b/README.md
@@@ -525,5 +525,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-sdk: v0.91.0
--- openvibe-shared: v4.122.0
++- openvibe-shared: v4.123.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex a0e1987..150033a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,7 +10,7 @@
"packages": [
"openvibe-contracts v0.289.1",
"openvibe-sdk v0.91.0",
-- "openvibe-shared v4.122.0"
++ "openvibe-shared v4.123.0"
],
"summary": "OpenVibe.MediaHub: openvibe.download as a private drive. Account-only uploads stream into OpenVibe.Media (a plain form up to 256 MB, resumable parts beyond). Files are private. Share links expire in 1 hour to 7 days, need a signed-in downloader and can be limited to named accounts (matched by subject). Reports suspend a share for staff. Downloads are always sandboxed attachments. Per-person quotas, and account export and deletion (ADR-033). openvibe.video serves public watch pages and playlists for Live's recordings and clips (no uploads); openvibe.pics is parked and redirects to openvibe.network.",
"works": [
@@@ -44,7 +44,7 @@
],
"contracts": "openvibe-contracts v0.289.1",
"sdk": "openvibe-sdk v0.91.0",
-- "shared": "openvibe-shared v4.122.0",
++ "shared": "openvibe-shared v4.123.0",
"deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08, https://openvibe.video since 2026-10-12 and https://openvibe.pics since 2026-10-15.",
"updated": "2026-11-06"
}
ddiff --git a/migrations/0020_sharing.sql b/migrations/0020_sharing.sql
nnew file mode 100644
iindex 0000000..a3d1dc4
---- /dev/null
++++ b/migrations/0020_sharing.sql
@@@ -0,0 +1,78 @@
++-- phase: expand
++-- Sharing (design 2026-11-06, VISIBILITY-EVERYWHERE step 4): the tables openvibe-sdk/share v0.91.0's
++-- sharesSchema() creates, kept verbatim so MediaHub's database has the same shape as every other
++-- service that adopts it. Expand only: nothing is dropped or rewritten.
++
++-- phase: expand
++-- The one sharing system (openvibe-sdk/share v0.91.0), for the companions characters.
++-- Kept verbatim from openvibe-sdk src/share/schema.js, so the SDKs own store finds the tables it expects, and
++-- the test database (which applies this directory) has them too. The characters own rows live in
++-- exchange_characters; these rows are the audience, the members, the log and the delivery queue the SDK writes.
++
++
++CREATE TABLE IF NOT EXISTS shares (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
++ revision integer NOT NULL DEFAULT 1,
++ lock text NOT NULL DEFAULT 'none',
++ reseal_nonce_base text,
++ PRIMARY KEY (service, kind, resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_members (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
++ key_slot jsonb,
++ PRIMARY KEY (service, kind, resource_id, subject),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ key_slot jsonb,
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
++-- slot a transfer was offered with. Repeating this migration is a no-op.
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
++DO $$ BEGIN
++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
++EXCEPTION WHEN duplicate_object THEN NULL; END $$;
++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
++
ddiff --git a/migrations/0021_sharing_backfill.sql b/migrations/0021_sharing_backfill.sql
nnew file mode 100644
iindex 0000000..4861aab
---- /dev/null
++++ b/migrations/0021_sharing_backfill.sql
@@@ -0,0 +1,39 @@
++-- phase: migrate
++-- Every upload and album that existed before sharing is registered once, so canView has a row for all
++-- of them. Pictures and albums were always "anyone with the link", so they are Everyone; a video upload
++-- keeps its own column's words (public → Everyone, unlisted → the link, private → Only me).
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT
++ 'mediahub', 'picture', p.id,
++ CASE
++ WHEN p.owner LIKE 'user:usr\_%' THEN jsonb_build_object('type', 'user', 'id', substr(p.owner, 6))
++ WHEN p.owner LIKE 'guest:gst\_%' THEN jsonb_build_object('type', 'guest', 'id', substr(p.owner, 7))
++ ELSE jsonb_build_object('type', 'service', 'id', 'mediahub')
++ END,
++ 'public'
++FROM pics_images p WHERE p.state <> 'removed'
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
++
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT
++ 'mediahub', 'album', a.id,
++ CASE
++ WHEN a.owner LIKE 'user:usr\_%' THEN jsonb_build_object('type', 'user', 'id', substr(a.owner, 6))
++ WHEN a.owner LIKE 'guest:gst\_%' THEN jsonb_build_object('type', 'guest', 'id', substr(a.owner, 7))
++ ELSE jsonb_build_object('type', 'service', 'id', 'mediahub')
++ END,
++ 'public'
++FROM pics_albums a
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
++
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT
++ 'mediahub', 'video', v.id,
++ CASE
++ WHEN v.owner LIKE 'user:usr\_%' THEN jsonb_build_object('type', 'user', 'id', substr(v.owner, 6))
++ WHEN v.owner LIKE 'guest:gst\_%' THEN jsonb_build_object('type', 'guest', 'id', substr(v.owner, 7))
++ ELSE jsonb_build_object('type', 'service', 'id', 'mediahub')
++ END,
++ CASE v.visibility WHEN 'public' THEN 'public' WHEN 'unlisted' THEN 'link' ELSE 'private' END
++FROM video_uploads v WHERE v.state <> 'removed'
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
ddiff --git a/migrations/0022_pics_audience.sql b/migrations/0022_pics_audience.sql
nnew file mode 100644
iindex 0000000..fd54362
---- /dev/null
++++ b/migrations/0022_pics_audience.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- Sharing step 4 (design 2026-11-06): the share row is the authority; this column keeps the gathered
++-- pages and the album in step with it, the 1:1 mapping the other repos use. Expand only.
++ALTER TABLE pics_images ADD COLUMN IF NOT EXISTS audience text NOT NULL DEFAULT 'public';
++ALTER TABLE pics_albums ADD COLUMN IF NOT EXISTS audience text NOT NULL DEFAULT 'public';
ddiff --git a/package-lock.json b/package-lock.json
iindex 401324e..456ca89 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0",
"sharp": "^0.33.5"
},
@@@ -1654,9 +1654,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.122.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
-- "integrity": "sha512-/BneZ1PYDWUM/DBEb8EodvTbsK9a+5EGY+bM8zJi1vgL+D8ohau7EixB4uNcqD0RLXJMu4IUYtSdwb/QPPyPMA==",
++ "version": "4.123.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
++ "integrity": "sha512-9XLlRm24VEf/amaSHm+3OEKYKKrDj9ic4Fii9qsKwtUV9HilQ8cw6H1TNi27m/VIdJcvFkZASpkmxnIhQW+rkw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 4cbfe82..236818c 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0",
"sharp": "^0.33.5"
},
ddiff --git a/server/account.js b/server/account.js
iindex 5f209ae..0200935 100644
---- a/server/account.js
++++ b/server/account.js
@@@ -53,6 +53,23 @@ const TABLES = [
{ table: 'mh_sync_keys', subject: 'owner', value: AS_REQUESTER, file: 'sync_keys.json', columns: ['id', 'name', 'prefix', 'created_at', 'last_used_at', 'revoked_at'] },
];
++
++/** The sharing system's rows (openvibe-sdk/share): jsonb subjects, so the SDK's table map cannot name them. */
++async function shareExport(db, subject) {
++ const owned = await db.many("SELECT * FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['mediahub', subject]);
++ const member = await db.many("SELECT * FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['mediahub', subject]);
++ const asked = await db.many("SELECT * FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['mediahub', subject]);
++ return owned.length || member.length || asked.length ? [{ name: 'shares.json', content: { owned, member_of: member, requests: asked } }] : [];
++}
++async function shareErase(t, subjects) {
++ for (const subject of subjects) {
++ await t.exec("DELETE FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['mediahub', subject]);
++ await t.exec("DELETE FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['mediahub', subject]);
++ await t.exec("DELETE FROM share_transfers WHERE service = $1 AND (from_subject->>'id' = $2 OR to_subject->>'id' = $2)", ['mediahub', subject]);
++ await t.exec("DELETE FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['mediahub', subject]);
++ }
++}
++
function createAccount({ db, config, media, log = console, onMediaVodReady = null }) {
/** The object ids of a deletion, read before the rows go (extraErase runs after them). */
let pendingObjects = [];
@@@ -80,9 +97,11 @@ function createAccount({ db, config, media, log = console, onMediaVodReady = nul
return [
...(rows.length ? [{ name: 'playlist_items.json', content: rows }] : []),
...(albumRows.length ? [{ name: 'pics_album_images.json', content: albumRows }] : []),
++ ...await shareExport(db, subject),
];
},
-- extraErase: async () => {
++ extraErase: async (t, subjects) => {
++ await shareErase(t, subjects);
const vods = pendingVods;
pendingVods = [];
for (const id of vods) {
ddiff --git a/server/app.js b/server/app.js
iindex 9f29398..e32af0c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -75,16 +75,20 @@ async function createApp(opts = {}) {
const playlists = createPlaylists({ s, config, log });
// MediaHub's own events (server/events-outbox.js): written with the change, relayed to OpenVibe.Events.
const eventsOutbox = require('./events-outbox').createEventsOutbox({ s, config, log, fetchImpl: opts.fetchImpl || null });
-- const pics = createPics({ s, media, config, log, events: eventsOutbox });
++ const { createSharing, describeRef } = require('./sharing');
++ // Sharing (openvibe-sdk/share): the row next to each picture, album and video upload, and the one access check.
++ const sharing = opts.sharing || createSharing({ db: s.db, config, outbox: { emit: (envelope) => eventsOutbox.emit(envelope) }, now: s.now, fetchImpl, log });
++ const pics = createPics({ s, media, config, log, events: eventsOutbox, sharing });
const picsDescribe = opts.picsDescribe || require('./pics/describe').createPicDescriber({ s, media, config, fetchImpl, log });
// OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.
-- const videoUploads = require('./video/uploads').createUploads({ s, media, config, log, events: eventsOutbox });
++ const videoUploads = require('./video/uploads').createUploads({ s, media, config, log, events: eventsOutbox, sharing });
const videoAi = opts.videoAi || require('./video/ai-edit').createAiEdit({ s, media, config, fetchImpl, log });
const videoCuts = require('./video/cut').createCuts({ s, media, uploads: videoUploads, videoAi, config, log, spawnImpl: opts.spawnImpl });
const syncKeys = createSyncKeys({ s, config });
const drops = createDrops({ s, media, config, uploads, log });
const videoSearch = require('./video/search-index').createVideoSearchIndex({ s, config, videoAi, fetchImpl, log });
-- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, picsDescribe, eventsOutbox, videoUploads, videoAi, videoCuts, syncKeys, drops, videoSearch, fetchImpl, log };
++ if (sharing.onChanged) sharing.onChanged((ref) => { if (ref.kind === 'video' && videoSearch) videoSearch.kick(); });
++ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, picsDescribe, eventsOutbox, videoUploads, videoAi, videoCuts, syncKeys, drops, videoSearch, fetchImpl, log, sharing };
// 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,6 +146,8 @@ async function createApp(opts = {}) {
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: 'same-site' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
++ // No HSTS in tests: a test server speaks plain HTTP, and HSTS would upgrade the page's own assets.
++ ...(config.nodeEnv === 'test' ? { strictTransportSecurity: false } : {}),
}));
app.use(cookieParser());
@@@ -240,6 +246,18 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.MEDIAHUB_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
++ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
++ {
++ const shareApi = express.Router();
++ shareApi.use(sso.middleware());
++ shareApi.use(sharing.shares.router({
++ service: 'mediahub',
++ authenticate: (req) => sharing.shareViewer(req.viewer),
++ describe: describeRef(s.db, config),
++ }));
++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;
++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
++ }
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
ddiff --git a/server/events-outbox.js b/server/events-outbox.js
iindex 4ebe112..d8c3519 100644
---- a/server/events-outbox.js
++++ b/server/events-outbox.js
@@@ -32,6 +32,8 @@ function createEventsOutbox({ s, config, log = console, fetchImpl = null }) {
return {
EVENT_TYPES,
++ /** Any envelope (the sharing system's share.* events) through the same at-least-once queue. */
++ emit: (envelope) => out.emit(envelope),
/** Inside the transaction that stores the picture; signed-out pictures emit nothing. */
async pictureUploaded(row) {
const owner = subjectOf(row.owner);
ddiff --git a/server/pics/api.js b/server/pics/api.js
iindex 895e854..03f4501 100644
---- a/server/pics/api.js
++++ b/server/pics/api.js
@@@ -95,6 +95,7 @@ function createPicsApi(ctx) {
r.get('/images/:slug', async (req, res) => {
const row = await load(req, res);
if (!row) return undefined;
++ if (ctx.sharing && await ctx.sharing.shareOf('picture', row.id).catch(() => null) && !await ctx.sharing.canView(req.viewer, 'picture', row.id).catch(() => false)) return problem(req, res, 404, 'pics.image_not_found', 'No such picture.');
return res.json(pics.toWire(row, { owner: pics.isOwner(row, whoOf(req)) }));
});
@@@ -164,6 +165,7 @@ function createPicsApi(ctx) {
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;
++ if (!owner && ctx.sharing && await ctx.sharing.shareOf('album', a.id).catch(() => null) && !await ctx.sharing.canView(req.viewer, 'album', a.id).catch(() => false)) return problem(req, res, 404, 'pics.album_not_found', 'No such album.');
return res.json(albumWire(a, (await store.albumImages(s, a.id, { visibleOnly: !owner })).map((x) => pics.toWire(x, { owner }))));
});
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex d8c7670..6ffeebf 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -43,12 +43,15 @@ const kit = require('openvibe-shared/kit');
const { PATHS: POLICY_PATHS, policyPage } = require('./policy');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
--const FILE_CACHE = 'public, max-age=600, s-maxage=600';
++const FILE_CACHE = 'public, max-age=600, s-maxage=60';
++/** Only Everyone and link pictures may sit in a shared cache; anything narrower is private to each answer. */
++const cacheFor = (row) => (row.state === 'active' && (row.audience === 'public' || row.audience === 'link' || row.audience === undefined) ? FILE_CACHE : 'private, no-store');
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 sharing = ctx.sharing;
const cfg = config.pics;
const base = cfg.url;
const host = (() => { try { return new URL(base).hostname; } catch { return 'openvibe.pics'; } })();
@@@ -107,6 +110,18 @@ function createPicsRoutes(ctx) {
},
}));
++ // ── /api/v1/<kind>/<id>/share — the uniform share API on this host ──────
++ if (sharing) {
++ const shareApi = express.Router();
++ shareApi.use(sharing.shares.router({
++ service: 'mediahub',
++ authenticate: (req) => sharing.shareViewer(req.viewer),
++ describe: require('../sharing').describeRef(s.db, config),
++ }));
++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;
++ r.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
++ }
++
// ── 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' })));
@@@ -124,7 +139,7 @@ function createPicsRoutes(ctx) {
'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',
++ 'Cache-Control': cacheFor(row),
'Content-Disposition': `inline; filename="${row.slug}.${row.ext}"`,
ETag: `"${row.sha256.slice(0, 32)}"`,
});
@@@ -144,6 +159,10 @@ function createPicsRoutes(ctx) {
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 };
++ // The share row is the authority once it exists: Only me and Specific people never serve the page or the
++ // bytes. A picture from before the sharing model has no row; its page was always link-only, so it stays.
++ const share = sharing ? await sharing.shareOf('picture', row.id).catch(() => null) : null;
++ if (share && !await sharing.canView(req.viewer, 'picture', row.id).catch(() => false)) return { row, status: 404, denied: true };
return { row, status: 200 };
}
@@@ -151,7 +170,7 @@ function createPicsRoutes(ctx) {
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();
++ if (req.get('if-none-match') === `"${row.sha256.slice(0, 32)}"` && row.state === 'active') return res.status(304).set('Cache-Control', cacheFor(row)).end();
return serveObject(req, res, row, row.object_id, row.mime_type);
});
@@@ -169,7 +188,7 @@ function createPicsRoutes(ctx) {
'Cross-Origin-Resource-Policy': 'cross-origin',
'Access-Control-Allow-Origin': '*',
'X-Robots-Tag': 'noindex',
-- 'Cache-Control': FILE_CACHE,
++ 'Cache-Control': cacheFor(row),
'Content-Disposition': `inline; filename="${row.slug}-${req.params[1]}.${req.params[2]}"`,
'Content-Length': String(v.buffer.length),
});
@@@ -356,7 +375,7 @@ ${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff l
${labelsUi.chips(labelsUi.inForce(row))}
<figure class="ovp-figure"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner, opened: true })}><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" 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>
--${raw(shareButton({ url: L.page, title, text: row.description || `${title}: a picture on ${SITE_NAME}.`, image: L.direct }))}
++${raw(shareButton({ url: L.page, title, text: row.description || `${title}: a picture on ${SITE_NAME}.`, image: L.direct, kind: 'picture', id: row.id }))}
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}
</article>
@@@ -405,7 +424,20 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
}
r.get('/i/:slug', async (req, res) => {
-- const { row, status } = await picture(req, res);
++ const { row, status, denied } = await picture(req, res);
++ if (denied && row) {
++ const share = sharing ? await sharing.shareOf('picture', row.id).catch(() => null) : null;
++ if (share && share.audience === 'people') {
++ return page(req, res, {
++ title: 'A picture on OpenVibe.Pics', noindex: true,
++ body: html`<header class="ovp-page-head"><h1>A picture on OpenVibe.Pics</h1><p class="muted">This is shared with specific people.</p></header>
++<div id="share-gate" data-ov-share-gate data-kind="picture" data-id="${String(row.id)}" data-signed-in-as="${req.viewer && req.viewer.username ? req.viewer.username : ''}" data-sign-in-url="${`/auth/login?next=${encodeURIComponent(req.originalUrl)}`}">
++<p class="muted">This picture is shared with specific people on OpenVibe.Pics.</p><p>${signInLink(req.originalUrl)}</p></div>
++<link rel="stylesheet" href="${ovServe.url('ov-share.css')}">
++<script src="${ovServe.url('ov-share.js')}" defer></script>`,
++ }, 404);
++ }
++ }
if (!row || status !== 200) return notFound(req, res, status);
return picturePage(req, res, row);
});
@@@ -531,13 +563,18 @@ ${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class
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 });
++ if (!owner && sharing) {
++ const share = await sharing.shareOf('album', album.id).catch(() => null);
++ if (share && !await sharing.canView(req.viewer, 'album', album.id).catch(() => false)) return notFound(req, res);
++ }
++ const rows = (await store.albumImages(s, album.id, { visibleOnly: !owner })).filter((i) => owner || i.audience === 'public' || i.audience === 'link');
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')}
++<p class="ovp-share">${raw(shareButton({ url: `${base}/a/${album.slug}`, title: album.title, text: album.description || `${album.title}: an album on ${SITE_NAME}.`, kind: 'album', id: album.id }))}</p>
${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>
ddiff --git a/server/pics/service.js b/server/pics/service.js
iindex c1725d9..2207241 100644
---- a/server/pics/service.js
++++ b/server/pics/service.js
@@@ -47,7 +47,7 @@ const cleanText = (v, max) => String(v == null ? '' : v).replace(/\r\n?/g, '\n')
/** 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, events = null }) {
++function createPics({ s, media, config, log = console, events = null, sharing = null }) {
const cfg = config.pics;
const addressKey = crypto.createHash('sha256').update(`pics-address:${config.oauth.clientSecret || 'dev'}`).digest();
@@@ -136,6 +136,8 @@ function createPics({ s, media, config, log = console, events = null }) {
});
if (events && !anonymous) await events.pictureUploaded(r);
return r; });
++ // Everyone by default: a picture has always been reachable by its link. The person can narrow it in the sheet.
++ if (sharing) await sharing.register(row.owner, 'picture', row.id, 'public');
return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}), ...(similar ? { similar: { id: similar.id, slug: similar.slug } } : {}) };
}
@@@ -228,6 +230,7 @@ function createPics({ s, media, config, log = console, events = null }) {
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() });
++ if (sharing) await sharing.register(owner, 'album', row.id, 'public');
return { ok: true, album: row };
}
ddiff --git a/server/render/html.js b/server/render/html.js
iindex 792cc46..9f423e3 100644
---- a/server/render/html.js
++++ b/server/render/html.js
@@@ -54,10 +54,10 @@ function shareButton({ title = '', url = '', text = '', image = '', embed = '',
*
* Nothing is stored and no clip is made: a moment is the page's own address with a time on it.
*/
--function shareButton({ url = '', title = '', text = '', image = '', timeFrom = null, cls = '', label = 'Share', icon = 'fa-solid fa-share-nodes' }) {
++function shareButton({ url = '', title = '', text = '', image = '', timeFrom = null, kind = null, id = null, cls = '', label = 'Share', icon = 'fa-solid fa-share-nodes' }) {
const line = String(text || '').trim().replace(/\s+/g, ' ');
const summary = line.length > 240 ? `${line.slice(0, 237).trimEnd()}…` : line;
-- return html`<button type="button" class="${cls}" data-ov-share-sheet${url ? html` data-url="${url}"` : ''}${title ? html` data-title="${title}"` : ''}${summary ? html` data-text="${summary}"` : ''}${image ? html` data-image="${image}"` : ''}${timeFrom ? html` data-time-from="${timeFrom}"` : ''}><i class="${icon}" aria-hidden="true"></i> ${label}</button>`;
++ return html`<button type="button" class="${cls}" data-ov-share-sheet${url ? html` data-url="${url}"` : ''}${title ? html` data-title="${title}"` : ''}${summary ? html` data-text="${summary}"` : ''}${image ? html` data-image="${image}"` : ''}${timeFrom ? html` data-time-from="${timeFrom}"` : ''}${kind && id ? html` data-kind="${kind}" data-id="${id}"` : ''}><i class="${icon}" aria-hidden="true"></i> ${label}</button>`;
}
const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..29c4c9d
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,175 @@
++'use strict';
++
++/**
++ * Sharing for MediaHub uploads and albums (design 2026-11-06, VISIBILITY-EVERYWHERE step 4;
++ * openvibe-sdk/share). The person's choice at upload, Everyone by default.
++ *
++ * The SDK owns the model and the rules: the `shares` row next to each picture, video, download or
++ * album, the one access check (canView), members, requests, transfers, the log and the queue. This
++ * file is the wiring a service supplies:
++ *
++ * - network.json Network's identity lookups (@name → subject, a person's card for the sheet)
++ * - events.publish MediaHub's Events outbox
++ * - notify Network's notification push; a refusal is logged, never allowed to stall the queue
++ *
++ * Pictures and albums have no visibility column: the share row is the authority. Their pages were
++ * always unlisted and never indexed, so Everyone here means "anyone with the link", exactly as the
++ * product already worked; Only me and Specific people are the new, narrower choices.
++ */
++const { createShares } = require('openvibe-sdk/share');
++const { serviceAuth } = require('openvibe-contracts');
++
++const SERVICE = 'mediahub';
++const KINDS = ['picture', 'album', 'video', 'download'];
++const AUDIENCES = ['public', 'link', 'people', 'private'];
++/** MediaHub's thread words to the SDK's (there is no column to keep in step). */
++const TO_SDK = { public: 'public', unlisted: 'link', private: 'private' };
++const FROM_SDK = { public: 'public', link: 'unlisted', people: 'private', private: 'private' };
++
++const refOf = (kind, id) => ({ service: SERVICE, kind: String(kind), id: String(id) });
++
++/** MediaHub's viewer as the SDK wants it: a subject object, and the staff bit. */
++function shareViewer(v) {
++ if (!v || !v.subject) return null;
++ if (String(v.subject).startsWith('usr_')) return { subject: { type: 'user', id: v.subject }, staff: !!v.staff };
++ if (String(v.subject).startsWith('gst_')) return { subject: { type: 'guest', id: v.subject }, staff: !!v.staff };
++ return null;
++}
++
++/** A `user:usr_…` requester (what the pictures' owner column holds) as a share subject. */
++const subjectOf = (requester) => {
++ const s = String(requester || '');
++ if (s.startsWith('user:usr_')) return { type: 'user', id: s.slice(5) };
++ if (s.startsWith('guest:gst_')) return { type: 'guest', id: s.slice(6) };
++ return null;
++};
++
++function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');
++ const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network', scope: 'identity.subject.resolve network.notifications.push', fetchImpl,
++ }) : null;
++
++ async function networkJson({ path, method = 'GET', json, query } = {}) {
++ if (!tokens) throw new Error('Network is not configured for this service');
++ const url = new URL(`${base}${path}`);
++ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
++ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
++ if (json !== undefined) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
++ const body = await res.json().catch(() => null);
++ if (!res.ok) {
++ const err = new Error(`Network ${method} ${path} ${res.status}: ${(body && (body.detail || body.error)) || 'failed'}`);
++ err.status = res.status;
++ throw err;
++ }
++ return body;
++ }
++
++ /** One notification, addressed by canonical subject. It never carries an upload's title. */
++ async function notify(n) {
++ const to = n && n.to;
++ if (!to || to.type !== 'user') return;
++ const words = {
++ 'share.added': ['Something was shared with you', 'Open it on OpenVibe.MediaHub.'],
++ 'share.access_requested': ['Someone asked for access', 'Review the request on OpenVibe.MediaHub.'],
++ 'share.transfer.requested': ['Someone wants to give you an upload', 'Accept or decline it on OpenVibe.MediaHub.'],
++ 'share.transfer.answered': ['An upload transfer was answered', 'See what happened on OpenVibe.MediaHub.'],
++ }[n.kind] || ['Sharing on OpenVibe.MediaHub', 'There is something new for you.'];
++ try {
++ await networkJson({ path: '/internal/notifications/push', method: 'POST', json: {
++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
++ service: 'mediahub', url: config.baseUrl, category: 'social',
++ } });
++ } catch (err) { log.warn('[Sharing] notification not sent:', err.message); }
++ }
++
++ let changedHook = null;
++ const shares = createShares({
++ db, network: { json: networkJson }, events: { publish: async (envelope) => await outbox.emit(envelope) }, notify,
++ // The audience word goes into the item's own column, the 1:1 mapping, so every gathered page that filters on
++ // it (the gallery, albums, the album page, the sitemap) stops listing an item the owner narrowed. An album's
++ // pictures follow the album (design 2026-11-06). Video's column is its old visibility word.
++ onAudienceChange: async (ref, audience, tx) => {
++ if (ref.kind === 'picture' || ref.kind === 'album') {
++ const table = ref.kind === 'picture' ? 'pics_images' : 'pics_albums';
++ await tx.exec(`UPDATE ${table} SET audience = $1 WHERE id = $2`, [audience, ref.id]);
++ if (ref.kind === 'album') {
++ await tx.exec('UPDATE pics_images SET audience = $1 WHERE id IN (SELECT image_id FROM pics_album_images WHERE album_id = $2)', [audience, ref.id]);
++ await tx.exec("UPDATE shares SET audience = $1, revision = revision + 1 WHERE service = $2 AND kind = 'picture' AND resource_id IN (SELECT image_id FROM pics_album_images WHERE album_id = $3)", [audience, SERVICE, ref.id]);
++ }
++ } else if (ref.kind === 'video') {
++ const vis = audience === 'public' ? 'public' : audience === 'link' ? 'unlisted' : 'private';
++ await tx.exec('UPDATE video_uploads SET visibility = $1 WHERE id = $2', [vis, ref.id]);
++ }
++ // After commit, let the service re-send Search what it may keep (a tombstone once a video leaves Everyone).
++ if (changedHook && tx.afterCommit) tx.afterCommit(() => { try { changedHook(ref, audience); } catch {} });
++ },
++ });
++
++ const sv = (viewer) => shareViewer(viewer);
++ const at = (kind, id) => refOf(kind, id);
++
++ return {
++ shares,
++ SERVICE, KINDS, AUDIENCES, TO_SDK, FROM_SDK, refOf, shareViewer, subjectOf,
++ /** Register an upload or album with the sharing system, Everyone unless asked otherwise. */
++ register: async (requester, kind, id, audience = 'public') => await shares.register(at(kind, id), {
++ owner_subject: subjectOf(requester) || { type: 'service', id: SERVICE },
++ audience: TO_SDK[audience] || 'public',
++ }),
++ shareOf: async (kind, id) => await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, String(kind), String(id)]),
++ canView: async (viewer, kind, id) => await shares.canView(sv(viewer), at(kind, id)),
++ canEdit: async (viewer, kind, id) => await shares.canEdit(sv(viewer), at(kind, id)),
++ get: async (viewer, kind, id) => await shares.get(sv(viewer), at(kind, id)),
++ getWithRevision: async (viewer, kind, id) => await shares.getWithRevision(sv(viewer), at(kind, id)),
++ /** Called after a change commits (the video search index kicks itself). */
++ onChanged: (fn) => { changedHook = fn; },
++ setAudience: async (viewer, kind, id, audience) => await shares.setAudience(sv(viewer), at(kind, id), TO_SDK[audience] || 'public'),
++ addMembers: async (viewer, kind, id, list) => await shares.addMembers(sv(viewer), at(kind, id), list),
++ removeMember: async (viewer, kind, id, subject) => await shares.removeMember(sv(viewer), at(kind, id), subject),
++ update: async (viewer, kind, id, body, opts) => await shares.update(sv(viewer), at(kind, id), body, opts),
++ requestAccess: async (viewer, kind, id) => await shares.requestAccess(sv(viewer), at(kind, id)),
++ dismiss: async (viewer, kind, id, requestId) => await shares.dismiss(sv(viewer), at(kind, id), requestId),
++ transfer: async (viewer, kind, id, input) => await shares.transfer(sv(viewer), at(kind, id), input),
++ accept: async (viewer, kind, id, transferId) => await shares.accept(sv(viewer), at(kind, id), transferId),
++ decline: async (viewer, kind, id, transferId) => await shares.decline(sv(viewer), at(kind, id), transferId),
++ cancel: async (viewer, kind, id, transferId) => await shares.cancel(sv(viewer), at(kind, id), transferId),
++ lookup: async (viewer, name) => await shares.lookup(sv(viewer), name),
++ sharedWithMe: async (viewer, opts) => await shares.sharedWithMe(sv(viewer), { service: SERVICE, ...(opts || {}) }),
++ work: async ({ flush = true } = {}) => {
++ let expired = 0;
++ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Sharing] expiry sweep:', err.message); }
++ let sent = 0;
++ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Sharing] deliveries:', err.message); } }
++ return { expired, sent };
++ },
++ };
++}
++
++/** The uniform API's describe(ref): the sheet shows the upload's title and link to its owner. */
++function describeRef(db, config) {
++ return async (ref) => {
++ if (ref.service !== SERVICE) return {};
++ const base = String(config.baseUrl || '').replace(/\/+$/, '');
++ if (ref.kind === 'picture') {
++ const row = await db.maybe('SELECT id, slug, title FROM pics_images WHERE id = $1 AND state <> $2', [ref.id, 'removed']);
++ if (!row) return {};
++ return { title: row.title || 'A picture', link: `${base}/i/${row.slug}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };
++ }
++ if (ref.kind === 'album') {
++ const row = await db.maybe('SELECT id, slug, title FROM pics_albums WHERE id = $1', [ref.id]);
++ if (!row) return {};
++ return { title: row.title || 'An album', link: `${base}/a/${row.slug}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };
++ }
++ if (ref.kind === 'video') {
++ const row = await db.maybe('SELECT id, title FROM video_uploads WHERE id = $1', [ref.id]);
++ if (!row) return {};
++ return { title: row.title || 'A video', link: `${base}/u/${row.id}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };
++ }
++ return {};
++ };
++}
++
++module.exports = { createSharing, describeRef, shareViewer, subjectOf, refOf, AUDIENCES, TO_SDK, FROM_SDK, SERVICE, KINDS };
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex f4425f4..4af6971 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -235,8 +235,14 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
let row = await up.byId(req.params.id);
const who = whoOf(req);
if (!up.canSee(row, who)) return notFound(req, res);
-- if (row.status === 'processing') row = await up.refresh(row);
++ // Specific people and Only me: the share row decides, beside the old column. A row written before the
++ // sharing model (an import, a cut) does not exist yet: the column still decides, failing closed for private.
const own = who.requester && row.owner === who.requester;
++ if (!own && !who.staff && ctx.sharing) {
++ const share = await ctx.sharing.shareOf('video', row.id).catch(() => null);
++ if (share && !await ctx.sharing.canView(req.viewer, 'video', row.id).catch(() => false)) return notFound(req, res);
++ }
++ if (row.status === 'processing') row = await up.refresh(row);
const manage = own || who.staff;
const ready = row.status === 'ready';
const vod = ready ? await up.playback(row) : null;
ddiff --git a/server/video/uploads.js b/server/video/uploads.js
iindex 1eeefa8..5d7fa3a 100644
---- a/server/video/uploads.js
++++ b/server/video/uploads.js
@@@ -45,7 +45,7 @@ function kindOf(contentType, name) {
return ext && EXT_KINDS[ext[1]] ? EXT_KINDS[ext[1]] : null;
}
--function createUploads({ s, media, config, log = console, events = null }) {
++function createUploads({ s, media, config, log = console, events = null, sharing = null }) {
const cfg = config.videoUploads;
const addressKey = crypto.createHash('sha256').update(`video-address:${config.oauth.clientSecret || 'dev'}`).digest();
const addressId = (ip) => `ip:${crypto.createHmac('sha256', addressKey).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;
@@@ -105,6 +105,7 @@ function createUploads({ s, media, config, log = console, events = null }) {
await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)`,
[id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, size, PART_SIZE, Math.ceil(size / PART_SIZE), now]);
++ if (sharing) await sharing.register(who.requester, 'video', id, visibility);
return await byId(id);
}
@@@ -128,6 +129,7 @@ function createUploads({ s, media, config, log = console, events = null }) {
await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, 0, 0, $9, $9)`,
[id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, now]);
++ if (sharing) await sharing.register(who.requester, 'video', id, visibility);
return await byId(id);
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 63aa65d..631d980 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -28,6 +28,8 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion MediaHub applied',
pics_blocklist: 'staff\'s record of pictures taken down, not the person\'s data',
++ share_members: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',
++ share_access_requests: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',
},
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 23ba240..8d4d869 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -324,6 +324,21 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
return await projectsApi(req, raw, json, u, parts, url.searchParams);
}
++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ const body = JSON.parse(raw.toString('utf8') || '{}');
++ const results = {};
++ for (const id of body.subject_ids || []) {
++ const u = [...st.users.values()].find((x) => x.subject === id);
++ results[id] = u ? { subject: { type: 'user', id }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false } : null;
++ }
++ return json(200, { results });
++ }
++ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
++ const name = (url.searchParams.get('username') || '').toLowerCase();
++ const u = [...st.users.values()].find((x) => x.username.toLowerCase() === name);
++ return u ? json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false }) : json(404, { error: 'no such person' });
++ }
++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { sent: true });
return json(404, { error: 'not found' });
});
issuer = srv.url;
ddiff --git a/test/pics-sharing.test.js b/test/pics-sharing.test.js
nnew file mode 100644
iindex 0000000..8778d36
---- /dev/null
++++ b/test/pics-sharing.test.js
@@@ -0,0 +1,136 @@
++'use strict';
++/**
++ * Sharing on MediaHub pictures and albums (design 2026-11-06, VISIBILITY-EVERYWHERE step 4;
++ * openvibe-sdk/share). Everyone by default (a picture has always been reachable by its link),
++ * Only me and Specific people narrow it; the share row decides the page, the file, the thumbnail
++ * and an album, and the gate never shows the picture.
++ */
++const assert = require('assert');
++const http = require('http');
++const sharp = require('sharp');
++const { boot, check, done } = require('./helpers/boot');
++const picsStore = require('../server/pics/store');
++
++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 (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); resolve({ status: res.statusCode, headers: res.headers, text: buf.toString('utf8'), buffer: buf, json() { return JSON.parse(buf.toString('utf8')); } }); });
++ });
++ req.on('error', reject);
++ if (body != null) req.write(body);
++ req.end();
++ });
++}
++
++(async () => {
++ const t = await boot();
++ const get = hostClient(t);
++ const kate = t.network.addUser('kate');
++ const mate = t.network.addUser('milo');
++ const sam = t.network.addUser('sam');
++ let seq = 0;
++ const upload = async () => {
++ // Different bytes each time: identical bytes dedupe to the same picture (and its audience).
++ seq += 1;
++ const png = await sharp({ create: { width: 16, height: 16, channels: 3, background: { r: seq, g: (seq * 7) % 256, b: 99 } } }).png().toBuffer();
++ const out = await t.ctx.pics.upload({ requester: `user:${kate.subject}` }, { buffer: png, name: 'a.png' }, { title: 'Secret picture' });
++ assert.strictEqual(out.ok, true, JSON.stringify(out));
++ return out.image;
++ };
++ const share = (kind, id, o = {}) => get(`/api/v1/${kind}/${id}/share`, { headers: { 'x-requested-with': 'ov-share' }, ...o });
++ const putShare = (kind, id, body) => share(kind, id, { method: 'PUT', as: kate, json: body });
++ const row = async (id) => await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['mediahub', 'picture', id]);
++
++ await check('a new picture is registered Everyone, owned by its uploader', async () => {
++ const img = await upload();
++ const s = await row(img.id);
++ assert.strictEqual(s.audience, 'public');
++ assert.deepStrictEqual(s.owner_subject, { type: 'user', id: kate.subject });
++ assert.strictEqual((await get(`/i/${img.slug}`)).status, 200);
++ assert.strictEqual((await get(`/i/${img.slug}.png`)).status, 200);
++ });
++
++ await check('Only me: the page, the file and the thumbnail never answer anyone else', async () => {
++ const img = await upload();
++ const put = await putShare('picture', img.id, { audience: 'private' });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.strictEqual((await get(`/i/${img.slug}`, { as: sam })).status, 404);
++ assert.strictEqual((await get(`/i/${img.slug}.png`, { as: sam })).status, 404);
++ assert.strictEqual((await get(`/i/${img.slug}/thumb.webp`, { as: sam })).status, 404);
++ assert.strictEqual((await get(`/i/${img.slug}`, { as: kate })).status, 200);
++ });
++
++ await check('Specific people: the named person opens the picture; a stranger gets the gate and no image', async () => {
++ const img = await upload();
++ const put = await putShare('picture', img.id, { audience: 'people', members: [{ subject: { type: 'user', id: mate.subject }, role: 'viewer' }] });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.strictEqual((await get(`/i/${img.slug}`, { as: mate })).status, 200);
++ assert.strictEqual((await get(`/i/${img.slug}.png`, { as: mate })).status, 200);
++ const stranger = await get(`/i/${img.slug}`, { as: sam });
++ assert.strictEqual(stranger.status, 404);
++ assert.ok(stranger.text.includes('shared with specific people') && stranger.text.includes('data-ov-share-gate'));
++ assert.ok(!stranger.text.includes('Secret picture'), 'no title');
++ assert.strictEqual((await get(`/i/${img.slug}.png`, { as: sam })).status, 404);
++ assert.strictEqual((await share('picture', img.id, { as: sam })).status, 404, 'the sheet is the owner’s');
++ });
++
++ await check('an album has its own audience; Only me hides it from everyone else', async () => {
++ const made = await t.ctx.pics.createAlbum(`user:${kate.subject}`, { title: 'Private album' });
++ assert.strictEqual(made.ok, true, JSON.stringify(made));
++ const album = made.album;
++ assert.strictEqual((await t.ctx.s.db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['mediahub', 'album', album.id])).audience, 'public');
++ assert.strictEqual((await get(`/a/${album.slug}`)).status, 200);
++ assert.strictEqual((await putShare('album', album.id, { audience: 'private' })).status, 200);
++ assert.strictEqual((await get(`/a/${album.slug}`, { as: sam })).status, 404);
++ assert.strictEqual((await get(`/a/${album.slug}`, { as: kate })).status, 200);
++ });
++
++
++ await check('a narrowed picture leaves shared caches at once: private, no-store; Everyone keeps s-maxage=60', async () => {
++ const img = await upload();
++ const pub = await get(`/i/${img.slug}.png`);
++ assert.strictEqual(pub.status, 200);
++ assert.match(pub.headers['cache-control'], /^public/);
++ assert.match(pub.headers['cache-control'], /s-maxage=60/);
++ await putShare('picture', img.id, { audience: 'private' });
++ const owner = await get(`/i/${img.slug}.png`, { as: kate });
++ assert.strictEqual(owner.status, 200);
++ assert.strictEqual(owner.headers['cache-control'], 'private, no-store');
++ assert.strictEqual((await get(`/i/${img.slug}.png`)).status, 404);
++ const thumb = await get(`/i/${img.slug}/thumb.webp`, { as: kate });
++ if (thumb.status === 200) assert.strictEqual(thumb.headers['cache-control'], 'private, no-store');
++ });
++
++ await check('Only me leaves the album page, and an album carries its pictures', async () => {
++ const img = await upload();
++ const made = await t.ctx.pics.createAlbum(`user:${kate.subject}`, { title: 'A folder' });
++ assert.strictEqual(made.ok, true, JSON.stringify(made));
++ const album = made.album;
++ const imageRow = await picsStore.imageBySlug(t.ctx.s, img.slug);
++ const albumRow = await picsStore.albumBySlug(t.ctx.s, album.slug);
++ await t.ctx.pics.addToAlbum(albumRow, imageRow);
++ const open = await get(`/a/${album.slug}`);
++ assert.strictEqual(open.status, 200);
++ assert.ok(open.text.includes(`/i/${img.slug}.png`), 'the picture is in the album');
++ await putShare('picture', img.id, { audience: 'private' });
++ const hidden = await get(`/a/${album.slug}`);
++ assert.strictEqual(hidden.status, 200);
++ assert.ok(!hidden.text.includes(`/i/${img.slug}.png`), 'Only me is not in the album page');
++ assert.strictEqual((await get(`/i/${img.slug}.png`, { as: kate })).status, 200);
++ // The album's own audience carries its pictures.
++ assert.strictEqual((await putShare('album', album.id, { audience: 'private' })).status, 200);
++ assert.strictEqual((await t.ctx.s.db.maybe('SELECT audience FROM pics_images WHERE id = $1', [img.id])).audience, 'private');
++ assert.strictEqual((await t.ctx.s.db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['mediahub', 'picture', img.id])).audience, 'private');
++ });
++
++ await done();
++ await t.close();
++})();
ddiff --git a/test/pics.test.js b/test/pics.test.js
iindex 40f1e42..ed9fb4a 100644
---- a/test/pics.test.js
++++ b/test/pics.test.js
@@@ -120,7 +120,7 @@ function multipart(fields, file) {
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');
++ assert.strictEqual(file.headers.get('cache-control'), 'public, max-age=600, s-maxage=60');
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');
@@@ -165,7 +165,7 @@ function multipart(fields, file) {
const webp = await t.get(`/i/${slug}/w/320.webp`, { headers: PICS });
assert.strictEqual(webp.status, 200);
assert.strictEqual(webp.headers.get('content-type'), 'image/webp');
-- assert.strictEqual(webp.headers.get('cache-control'), 'public, max-age=600, s-maxage=600');
++ assert.strictEqual(webp.headers.get('cache-control'), 'public, max-age=600, s-maxage=60');
const meta = await sharp(webp.buffer).metadata();
assert.deepStrictEqual([meta.format, meta.width], ['webp', 320]);
const png = await t.get(`/i/${slug}/w/640.png`, { headers: PICS });
ddiff --git a/test/share-gate-chrome.test.js b/test/share-gate-chrome.test.js
nnew file mode 100644
iindex 0000000..38bddb8
---- /dev/null
++++ b/test/share-gate-chrome.test.js
@@@ -0,0 +1,114 @@
++'use strict';
++/**
++ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]),
++ * on the pics host: markup only; signed out it offers Sign in, a signed-in stranger is offered Ask for
++ * access, and the page has no console error and no CSP refusal. Skipped without Chrome.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const sharp = require('sharp');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++/** The page's own /shared asset, fetched over plain HTTP from 127.0.0.1 with the pics Host. */
++function sharedFile(url) {
++ const u = new URL(url);
++ return new Promise((resolve, reject) => {
++ const q = http.request({ hostname: '127.0.0.1', port: u.port, path: u.pathname + u.search, headers: { Host: u.hostname } }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
++ });
++ q.on('error', reject);
++ q.end();
++ });
++}
++
++let playwright = null;
++try { playwright = require('playwright'); } catch {
++ try { playwright = require(process.env.PLAYWRIGHT_MODULE || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'); } catch { playwright = null; }
++}
++const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome';
++if (process.env.OV_SKIP_BROWSER === '1' || !playwright || !fs.existsSync(CHROME)) {
++ console.log('share-gate-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++const REFUSED = /Content Security Policy|Refused to (?:load|execute|apply)|Uncaught SyntaxError/i;
++
++(async () => {
++ const t = await boot({ env: { MEDIAHUB_PICS_URL: 'http://pics.test' } });
++ const kate = t.network.addUser('kate');
++ const sam = t.network.addUser('sam');
++ const png = await sharp({ create: { width: 16, height: 16, channels: 3, background: '#336699' } }).png().toBuffer();
++ const out = await t.ctx.pics.upload({ requester: `user:${kate.subject}` }, { buffer: png, name: 'a.png' }, { title: 'Secret picture' });
++ assert.strictEqual(out.ok, true, JSON.stringify(out));
++ const picture = out.image;
++ await t.ctx.sharing.shares.setAudience(
++ { subject: { type: 'user', id: kate.subject } }, { service: 'mediahub', kind: 'picture', id: picture.id }, 'people',
++ );
++ const port = new URL(t.base).port;
++ const origin = `http://pics.test:${port}`;
++ const url = `${origin}/i/${picture.slug}`;
++ const [cookieName, ...cookieRest] = t.signIn(sam).split('=');
++
++ let browser;
++ const errors = [];
++ const watch = (p) => {
++ p.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
++ p.on('pageerror', (e) => errors.push(String(e)));
++ };
++ const settled = async (p) => {
++ await p.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });
++ return await p.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());
++ };
++
++ try {
++ await check('headless Chrome: the gate draws itself, signed out, with Sign in', async () => {
++ browser = await playwright.chromium.launch({
++ executablePath: CHROME, headless: true,
++ args: ['--no-sandbox', '--disable-dev-shm-usage', '--host-resolver-rules=MAP pics.test 127.0.0.1'],
++ });
++ const ctx = await browser.newContext();
++ const p = await ctx.newPage();
++ await p.route('**/shared/**', async (r) => {
++ const res = await sharedFile(r.request().url());
++ const headers = { ...res.headers };
++ delete headers['content-encoding']; delete headers['content-length']; delete headers['transfer-encoding'];
++ await r.fulfill({ status: res.status, headers, body: res.body });
++ });
++ watch(p);
++ const res = await p.goto(url, { waitUntil: 'load', timeout: 15000 });
++ const text = await settled(p);
++ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);
++ const href = await p.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));
++ assert.match(href, /^\/auth\/login\?next=/);
++ await ctx.close();
++ });
++
++ await check('headless Chrome: a stranger is offered Ask for access', async () => {
++ const ctx = await browser.newContext();
++ await ctx.addCookies([{ name: cookieName, value: cookieRest.join('='), url: origin }]);
++ const p = await ctx.newPage();
++ await p.route('**/shared/**', async (r) => {
++ const res = await sharedFile(r.request().url());
++ const headers = { ...res.headers };
++ delete headers['content-encoding']; delete headers['content-length']; delete headers['transfer-encoding'];
++ await r.fulfill({ status: res.status, headers, body: res.body });
++ });
++ watch(p);
++ await p.goto(url, { waitUntil: 'load', timeout: 15000 });
++ const text = await settled(p);
++ assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);
++ assert.ok(/@sam/.test(text), 'the gate names the account it is signed in as');
++ await ctx.close();
++ });
++
++ await check('headless Chrome: no console error and no CSP refusal', async () => {
++ assert.deepStrictEqual(errors.filter((e) => REFUSED.test(e)), []);
++ });
++ } finally {
++ if (browser) await browser.close().catch(() => {});
++ await t.close();
++ done();
++ }
++})();