openvibers/openvibe.mediahub Commits
MediaHub: the four audiences on pictures, albums and video uploads (sharing step 4) (#295)
Changes
@@@ -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 -->@@@ -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"}nnew file mode 100644@@@ -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;++nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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';@@@ -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",@@@ -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"},@@@ -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 = nulreturn [...(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) {@@@ -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 }));@@@ -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);@@@ -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 }))));});@@@ -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 classconst 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>@@@ -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 };}@@@ -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>` : '—');nnew file mode 100644@@@ -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 };@@@ -235,8 +235,14 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${stlet 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;@@@ -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);}@@@ -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(', ')}`);@@@ -324,6 +324,21 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mconst 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;nnew file mode 100644@@@ -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();++})();@@@ -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 });nnew file mode 100644@@@ -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();++ }++})();
@@ -525,5 +525,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -525,5 +525,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-contracts: v0.289.1
- openvibe-sdk: v0.91.0
- openvibe-sdk: v0.91.0
-- openvibe-shared: v4.122.0
+- openvibe-shared: v4.123.0
<!-- versions:end -->
<!-- versions:end -->
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
"packages": [
"packages": [
"openvibe-contracts v0.289.1",
"openvibe-contracts v0.289.1",
"openvibe-sdk v0.91.0",
"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.",
"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": [
"works": [
@@ -44,7 +44,7 @@
@@ -44,7 +44,7 @@
],
],
"contracts": "openvibe-contracts v0.289.1",
"contracts": "openvibe-contracts v0.289.1",
"sdk": "openvibe-sdk v0.91.0",
"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.",
"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"
"updated": "2026-11-06"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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;
+
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -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';
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"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-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",
"pg": "^8.23.0",
"sharp": "^0.33.5"
"sharp": "^0.33.5"
},
},
@@ -1654,9 +1654,9 @@
@@ -1654,9 +1654,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"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-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",
"pg": "^8.23.0",
"sharp": "^0.33.5"
"sharp": "^0.33.5"
},
},
@@ -53,6 +53,23 @@ const TABLES = [
@@ -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'] },
{ 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 }) {
function createAccount({ db, config, media, log = console, onMediaVodReady = null }) {
/** The object ids of a deletion, read before the rows go (extraErase runs after them). */
/** The object ids of a deletion, read before the rows go (extraErase runs after them). */
let pendingObjects = [];
let pendingObjects = [];
@@ -80,9 +97,11 @@ function createAccount({ db, config, media, log = console, onMediaVodReady = nul
@@ -80,9 +97,11 @@ function createAccount({ db, config, media, log = console, onMediaVodReady = nul
return [
return [
...(rows.length ? [{ name: 'playlist_items.json', content: rows }] : []),
...(rows.length ? [{ name: 'playlist_items.json', content: rows }] : []),
...(albumRows.length ? [{ name: 'pics_album_images.json', content: albumRows }] : []),
...(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;
const vods = pendingVods;
pendingVods = [];
pendingVods = [];
for (const id of vods) {
for (const id of vods) {
@@ -75,16 +75,20 @@ async function createApp(opts = {}) {
@@ -75,16 +75,20 @@ async function createApp(opts = {}) {
const playlists = createPlaylists({ s, config, log });
const playlists = createPlaylists({ s, config, log });
// MediaHub's own events (server/events-outbox.js): written with the change, relayed to OpenVibe.Events.
// 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 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 });
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.
// 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 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 videoCuts = require('./video/cut').createCuts({ s, media, uploads: videoUploads, videoAi, config, log, spawnImpl: opts.spawnImpl });
const syncKeys = createSyncKeys({ s, config });
const syncKeys = createSyncKeys({ s, config });
const drops = createDrops({ s, media, config, uploads, log });
const drops = createDrops({ s, media, config, uploads, log });
const videoSearch = require('./video/search-index').createVideoSearchIndex({ s, config, videoAi, fetchImpl, 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.
// 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,6 +146,8 @@ async function createApp(opts = {}) {
@@ -142,6 +146,8 @@ async function createApp(opts = {}) {
crossOriginEmbedderPolicy: false,
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: 'same-site' },
crossOriginResourcePolicy: { policy: 'same-site' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
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());
app.use(cookieParser());
@@ -240,6 +246,18 @@ async function createApp(opts = {}) {
@@ -240,6 +246,18 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
// ── 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 }) }));
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/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
@@ -32,6 +32,8 @@ function createEventsOutbox({ s, config, log = console, fetchImpl = null }) {
@@ -32,6 +32,8 @@ function createEventsOutbox({ s, config, log = console, fetchImpl = null }) {
return {
return {
EVENT_TYPES,
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. */
/** Inside the transaction that stores the picture; signed-out pictures emit nothing. */
async pictureUploaded(row) {
async pictureUploaded(row) {
const owner = subjectOf(row.owner);
const owner = subjectOf(row.owner);
@@ -95,6 +95,7 @@ function createPicsApi(ctx) {
@@ -95,6 +95,7 @@ function createPicsApi(ctx) {
r.get('/images/:slug', async (req, res) => {
r.get('/images/:slug', async (req, res) => {
const row = await load(req, res);
const row = await load(req, res);
if (!row) return undefined;
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)) }));
return res.json(pics.toWire(row, { owner: pics.isOwner(row, whoOf(req)) }));
});
});
@@ -164,6 +165,7 @@ function createPicsApi(ctx) {
@@ -164,6 +165,7 @@ function createPicsApi(ctx) {
const a = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;
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.');
if (!a) return problem(req, res, 404, 'pics.album_not_found', 'No such album.');
const owner = a.owner === whoOf(req).requester;
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 }))));
return res.json(albumWire(a, (await store.albumImages(s, a.id, { visibleOnly: !owner })).map((x) => pics.toWire(x, { owner }))));
});
});
@@ -43,12 +43,15 @@ const kit = require('openvibe-shared/kit');
@@ -43,12 +43,15 @@ const kit = require('openvibe-shared/kit');
const { PATHS: POLICY_PATHS, policyPage } = require('./policy');
const { PATHS: POLICY_PATHS, policyPage } = require('./policy');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
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' };
const KIND = { jpg: 'JPEG', png: 'PNG', gif: 'GIF', webp: 'WebP', avif: 'AVIF' };
function createPicsRoutes(ctx) {
function createPicsRoutes(ctx) {
const { config, s, media, log } = ctx;
const { config, s, media, log } = ctx;
const pics = ctx.pics;
const pics = ctx.pics;
+ const sharing = ctx.sharing;
const cfg = config.pics;
const cfg = config.pics;
const base = cfg.url;
const base = cfg.url;
const host = (() => { try { return new URL(base).hostname; } catch { return 'openvibe.pics'; } })();
const host = (() => { try { return new URL(base).hostname; } catch { return 'openvibe.pics'; } })();
@@ -107,6 +110,18 @@ function createPicsRoutes(ctx) {
@@ -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 ────────────────────────────────
// ── The API on this host ────────────────────────────────
r.use('/api/v1', createPicsApi(ctx));
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' })));
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) {
@@ -124,7 +139,7 @@ function createPicsRoutes(ctx) {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Origin': '*',
'X-Robots-Tag': 'noindex',
'X-Robots-Tag': 'noindex',
// Hidden pictures leave shared caches within ten minutes; their uploader and staff get no-store copies.
// 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}"`,
'Content-Disposition': `inline; filename="${row.slug}.${row.ext}"`,
ETag: `"${row.sha256.slice(0, 32)}"`,
ETag: `"${row.sha256.slice(0, 32)}"`,
});
});
@@ -144,6 +159,10 @@ function createPicsRoutes(ctx) {
@@ -144,6 +159,10 @@ function createPicsRoutes(ctx) {
if (!row) return { row: null, status: 404 };
if (!row) return { row: null, status: 404 };
if (row.state === 'removed') return { row, status: 410 };
if (row.state === 'removed') return { row, status: 410 };
if (!pics.canSee(row, whoOf(req))) return { row: null, status: 404 };
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 };
return { row, status: 200 };
}
}
@@ -151,7 +170,7 @@ function createPicsRoutes(ctx) {
@@ -151,7 +170,7 @@ function createPicsRoutes(ctx) {
req.params.slug = req.params[0];
req.params.slug = req.params[0];
const { row, status } = await picture(req, res);
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 (!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);
return serveObject(req, res, row, row.object_id, row.mime_type);
});
});
@@ -169,7 +188,7 @@ function createPicsRoutes(ctx) {
@@ -169,7 +188,7 @@ function createPicsRoutes(ctx) {
'Cross-Origin-Resource-Policy': 'cross-origin',
'Cross-Origin-Resource-Policy': 'cross-origin',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Origin': '*',
'X-Robots-Tag': 'noindex',
'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-Disposition': `inline; filename="${row.slug}-${req.params[1]}.${req.params[2]}"`,
'Content-Length': String(v.buffer.length),
'Content-Length': String(v.buffer.length),
});
});
@@ -356,7 +375,7 @@ ${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff l
@@ -356,7 +375,7 @@ ${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff l
${labelsUi.chips(labelsUi.inForce(row))}
${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>
<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>
<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.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>` : ''}
${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>
</article>
@@ -405,7 +424,20 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
@@ -405,7 +424,20 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
}
}
r.get('/i/:slug', async (req, res) => {
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);
if (!row || status !== 200) return notFound(req, res, status);
return picturePage(req, res, row);
return picturePage(req, res, row);
});
});
@@ -531,13 +563,18 @@ ${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class
@@ -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;
const album = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;
if (!album) return notFound(req, res);
if (!album) return notFound(req, res);
const owner = album.owner === whoOf(req).requester;
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, {
return page(req, res, {
title: album.title, noindex: true, scripts: ['js/pics.js'],
title: album.title, noindex: true, scripts: ['js/pics.js'],
image: rows[0] ? `${base}/i/${rows[0].slug}.${rows[0].ext}` : undefined,
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>
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>` : ''}
${album.description ? html`<p class="ovp-desc">${album.description}</p>` : ''}
${copyRow('Link to this album', `${base}/a/${album.slug}`, 'c-album')}
${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.')}
${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>
${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>
<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>
@@ -47,7 +47,7 @@ const cleanText = (v, max) => String(v == null ? '' : v).replace(/\r\n?/g, '\n')
@@ -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. */
/** 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; };
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 cfg = config.pics;
const addressKey = crypto.createHash('sha256').update(`pics-address:${config.oauth.clientSecret || 'dev'}`).digest();
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 }) {
@@ -136,6 +136,8 @@ function createPics({ s, media, config, log = console, events = null }) {
});
});
if (events && !anonymous) await events.pictureUploaded(r);
if (events && !anonymous) await events.pictureUploaded(r);
return 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 } } : {}) };
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 }) {
@@ -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 (!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.` };
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() });
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 };
return { ok: true, album: row };
}
}
@@ -54,10 +54,10 @@ function shareButton({ title = '', url = '', text = '', image = '', embed = '',
@@ -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.
* 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 line = String(text || '').trim().replace(/\s+/g, ' ');
const summary = line.length > 240 ? `${line.slice(0, 237).trimEnd()}…` : line;
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>` : '—');
const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -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 };
@@ -235,8 +235,14 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
@@ -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);
let row = await up.byId(req.params.id);
const who = whoOf(req);
const who = whoOf(req);
if (!up.canSee(row, who)) return notFound(req, res);
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;
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 manage = own || who.staff;
const ready = row.status === 'ready';
const ready = row.status === 'ready';
const vod = ready ? await up.playback(row) : null;
const vod = ready ? await up.playback(row) : null;
@@ -45,7 +45,7 @@ function kindOf(contentType, name) {
@@ -45,7 +45,7 @@ function kindOf(contentType, name) {
return ext && EXT_KINDS[ext[1]] ? EXT_KINDS[ext[1]] : null;
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 cfg = config.videoUploads;
const addressKey = crypto.createHash('sha256').update(`video-address:${config.oauth.clientSecret || 'dev'}`).digest();
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)}`;
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 }) {
@@ -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)
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)`,
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]);
[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);
return await byId(id);
}
}
@@ -128,6 +129,7 @@ function createUploads({ s, media, config, log = console, events = null }) {
@@ -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)
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)`,
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]);
[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);
return await byId(id);
}
}
@@ -28,6 +28,8 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -28,6 +28,8 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion MediaHub applied',
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',
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(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
@@ -324,6 +324,21 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -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);
const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
return await projectsApi(req, raw, json, u, parts, url.searchParams);
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' });
return json(404, { error: 'not found' });
});
});
issuer = srv.url;
issuer = srv.url;
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -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();
+})();
@@ -120,7 +120,7 @@ function multipart(fields, file) {
@@ -120,7 +120,7 @@ function multipart(fields, file) {
assert.strictEqual(file.headers.get('content-type'), 'image/jpeg');
assert.strictEqual(file.headers.get('content-type'), 'image/jpeg');
assert.strictEqual(file.headers.get('x-content-type-options'), 'nosniff');
assert.strictEqual(file.headers.get('x-content-type-options'), 'nosniff');
assert.match(file.headers.get('content-security-policy'), /sandbox/);
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();
const meta = await sharp(file.buffer).metadata();
assert.strictEqual(meta.exif, undefined, 'no EXIF block');
assert.strictEqual(meta.exif, undefined, 'no EXIF block');
assert.ok(!file.buffer.includes(Buffer.from('SecretCam')), 'the camera name is gone');
assert.ok(!file.buffer.includes(Buffer.from('SecretCam')), 'the camera name is gone');
@@ -165,7 +165,7 @@ function multipart(fields, file) {
@@ -165,7 +165,7 @@ function multipart(fields, file) {
const webp = await t.get(`/i/${slug}/w/320.webp`, { headers: PICS });
const webp = await t.get(`/i/${slug}/w/320.webp`, { headers: PICS });
assert.strictEqual(webp.status, 200);
assert.strictEqual(webp.status, 200);
assert.strictEqual(webp.headers.get('content-type'), 'image/webp');
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();
const meta = await sharp(webp.buffer).metadata();
assert.deepStrictEqual([meta.format, meta.width], ['webp', 320]);
assert.deepStrictEqual([meta.format, meta.width], ['webp', 320]);
const png = await t.get(`/i/${slug}/w/640.png`, { headers: PICS });
const png = await t.get(`/i/${slug}/w/640.png`, { headers: PICS });
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -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();
+ }
+})();