openvibers/openvibe.mediahub Commits
Add agent file grants and source library view (#390)
Changes
@@@ -82,7 +82,13 @@ The service manifest (openvibe-contracts, `manifests/services/media-hub.json`) lThe two playlist capabilities are registered in `server/http/principal.js` and guarded on the video host's API(see **Playlist API**): `video.playlist.read` lets an app, agent or service list and read the playlists it owns andany public or unlisted playlist by id, and `video.playlist.manage` lets it create, edit, reorder and delete its own.--Every other `/api/v1` route still requires a signed-in person, never an app, agent or service token.++`download.file.read` is for agent tokens only, and reads one file only when its owner created an active grant for that++agent. A capability never lists the drive or replaces the per-file grant. Every other drive write still requires a++signed-in person (or that person's scoped sync key).++++The signed-in `/files` root and search also show that person's Pics images and ready Video uploads as read-only++entries. Their bytes and lifecycle remain with Pics and Video; opening an entry goes to its owning service. Those++source entries are not yet eligible for Download agent grants, which apply to files uploaded into the drive.On other services it calls:@@@ -406,12 +412,15 @@ local copy is gone. It authenticates with a **sync key** made on `/sync` (`OPENV| `GET /files`, `POST /files`, `GET /files/:id`, `DELETE /files/:id` | your files || `GET /folders`, `POST /folders` | your folders || `POST /shares`, `DELETE /shares/:slug` | make (`{ file_id \| folder_id, hours, usernames? }`) and revoke share links |++| `GET /agent-grants`, `POST /agent-grants`, `DELETE /agent-grants/:id` | list, make and revoke a read-only grant for one agent and one file; grants expire after 1–720 hours |++| `GET /agent/files/:id/content` | agent-only `download.file.read`; streams the granted file with byte-range support || `GET /uploads/:id`, `PUT /uploads/:id/parts/:n`, `POST /uploads/:id/complete`, `DELETE /uploads/:id` | resumable uploads || `GET /usage` | your usage against the limits |On the video host (openvibe.video), the playlist API accepts a person Bearer token, an app/agent/service token--with the right capability, or (for public reads) no token — see **Playlist API** below. The drive's `/api/v1`--routes are unchanged and remain a signed-in person only.++with the right capability, or (for public reads) no token — see **Playlist API** below. On the drive API, the++agent file-content route requires both an agent token with `download.file.read` and that agent's active per-file grant.++Other drive routes remain person-only, except for the owner's scoped sync key.## Playlist APInnew file mode 100644@@@ -0,0 +1,16 @@++-- phase: expand++-- Read-only agent access to a person's explicitly selected files. A capability is necessary to use the read route,++-- but each row is also bound to one file, one owner and one agent subject; grants can be revoked or expire.++CREATE TABLE mh_agent_grants (++ id text COLLATE "C" PRIMARY KEY, -- agr_<ULID>++ owner text COLLATE "C" NOT NULL, -- user:usr_…++ file_id text COLLATE "C" NOT NULL REFERENCES mh_files (id) ON DELETE CASCADE,++ agent_subject text COLLATE "C" NOT NULL, -- agent:agt_…++ created_at text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C" NOT NULL,++ revoked_at text COLLATE "C"++);++++CREATE INDEX mh_agent_grants_owner ON mh_agent_grants (owner, created_at DESC);++CREATE INDEX mh_agent_grants_live ON mh_agent_grants (file_id, agent_subject, expires_at)++ WHERE revoked_at IS NULL;@@@ -30,6 +30,7 @@ const TABLES = [{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },// Radio publications refer to drive files. Export and erase them before the file rows cascade.{ table: 'mh_radio_tracks', subject: 'owner', value: AS_REQUESTER, file: 'radio_tracks.json' },++ { table: 'mh_agent_grants', subject: 'owner', value: AS_REQUESTER, file: 'agent_grants.json' },{ table: 'mh_files', subject: 'owner', value: AS_REQUESTER, file: 'files.json' },{ table: 'mh_folders', subject: 'owner', value: AS_REQUESTER, file: 'folders.json' },// A playlist's items have no owner column of their own; they are exported with extraExport below and erased@@@ -146,6 +146,7 @@ function createService({ s, media, identity, config, log = console }) {const row = await ownFile(owner, id);if (!row) return { error: 'file.not_found' };await store.deleteSharesForFile(s, id);++ await store.revokeGrantsForFile(s, id, nowIso());await store.binFile(s, id, nowIso());return { file: await store.getFile(s, id) };}@@@ -163,12 +164,43 @@ function createService({ s, media, identity, config, log = console }) {/** What deleteFile did before the bin: the shares go, the row is soft-deleted and Media is asked for the object. */async function hardDelete(row) {await store.deleteSharesForFile(s, row.id);++ await store.revokeGrantsForFile(s, row.id, nowIso());await store.softDeleteFile(s, row.id, nowIso());const removed = await media.remove(row.object_id);if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${row.object_id}: ${removed.status} ${removed.code}`);return { file: row, media: removed };}++ /** Give one agent read access to one of this person's files for a bounded period. */++ async function createAgentGrant(owner, { fileId, agentSubject, hours = 24 }) {++ const file = await ownFile(owner, fileId);++ if (!file) return { error: 'file.not_found' };++ if (!/^agent:agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(agentSubject || ''))) return { error: 'agent.invalid' };++ const ttl = Number(hours);++ if (!Number.isInteger(ttl) || ttl < 1 || ttl > 720) return { error: 'grant.invalid_expiry' };++ const createdAt = nowIso();++ return { grant: await store.insertAgentGrant(s, {++ id: s.newId('agr'), owner, file_id: file.id, agent_subject: agentSubject,++ created_at: createdAt, expires_at: new Date(Date.parse(createdAt) + ttl * 60 * 60 * 1000).toISOString(),++ }) };++ }++++ async function revokeAgentGrant(owner, id) {++ const row = await store.getAgentGrant(s, id);++ if (!row || row.owner !== owner) return { error: 'grant.not_found' };++ await store.revokeAgentGrant(s, id, owner, nowIso());++ return { grant: await store.getAgentGrant(s, id) };++ }++++ const listAgentGrants = (owner, fileId = null) => store.listAgentGrants(s, owner, fileId);++ const activeAgentGrant = (fileId, agentSubject) => store.activeAgentGrant(s, fileId, agentSubject, nowIso());++ async function agentFile(fileId, agentSubject) {++ const grant = await activeAgentGrant(fileId, agentSubject);++ if (!grant) return null;++ const row = await store.getFile(s, fileId);++ return row && !row.deleted_at && !row.binned_at ? row : null;++ }++/** Delete one binned file forever. Only a file already in the bin resolves here. */async function purgeFile(owner, id) {const row = await ownBinnedFile(owner, id);@@@ -414,6 +446,28 @@ function createService({ s, media, identity, config, log = console }) {// ── Reads the pages and the API both need (no SQL outside this module) ──const listFiles = (owner, opts) => store.listFiles(s, owner, opts);++ /** Read-only source records from Pics and Video in the same MediaHub database; bytes remain with each product. */++ async function listSourceFiles(owner, { query = '', include = true } = {}) {++ if (!include) return [];++ const q = String(query || '').trim();++ const like = q ? `%${q.replace(/[\\%_]/g, '\\$&')}%` : null;++ const [pictures, videos] = await Promise.all([++ s.db.many(++ `SELECT id, slug, title, object_id, mime_type AS content_type, bytes AS size, created_at++ FROM pics_images WHERE owner = $1 AND state IN ('active', 'hidden')++ AND ($2::text IS NULL OR title ILIKE $2 ESCAPE '\\' OR slug ILIKE $2 ESCAPE '\\')++ ORDER BY id DESC LIMIT 200`, [owner, like]),++ s.db.many(++ `SELECT id, title, media_vod_id, content_type, size_bytes AS size, created_at++ FROM video_uploads WHERE owner = $1 AND state <> 'removed' AND status = 'ready' AND media_vod_id IS NOT NULL++ AND ($2::text IS NULL OR title ILIKE $2 ESCAPE '\\' OR id ILIKE $2 ESCAPE '\\')++ ORDER BY id DESC LIMIT 200`, [owner, like]),++ ]);++ return [++ ...pictures.map((row) => ({ ...row, name: String(row.title || '').trim() || `Picture ${row.slug}`, source: 'pics', source_label: 'OpenVibe.Pics', source_url: `${config.pics.url}/i/${encodeURIComponent(row.slug)}` })),++ ...videos.map((row) => ({ ...row, name: String(row.title || '').trim() || 'Video', source: 'video', source_label: 'OpenVibe.Video', source_url: `${config.video.url}/u/${encodeURIComponent(row.id)}` })),++ ];++ }const listBinned = (owner, opts) => store.listBinned(s, owner, opts);const binCount = (owner) => store.countBinned(s, owner);const listFolders = (owner) => store.listFolders(s, owner);@@@ -426,9 +480,10 @@ function createService({ s, media, identity, config, log = console }) {uploadAllowed, shareAllowed, usage, dayOf,recordFile, ownFile, ownBinnedFile, deleteFile, restoreFile, purgeFile, purgeDue, emptyBin, updateFile,renameFile, moveFile, searchFiles,-- listFiles, listBinned, binCount, listFolders, listShares, usageDays, shareBySlug, reportsFor,++ listFiles, listSourceFiles, listBinned, binCount, listFolders, listShares, usageDays, shareBySlug, reportsFor,ownFolder, createFolder, renameFolder, deleteFolder, folderPath,createShare, ownShare, revokeShare,++ createAgentGrant, revokeAgentGrant, listAgentGrants, activeAgentGrant, agentFile,report, restoreShare, removeShared,stateOf, allowedFor, parseAllow,};@@@ -17,6 +17,7 @@ const SHARES = 'mh_shares';const REPORTS = 'mh_reports';const USAGE = 'mh_usage';const UPLOADS = 'mh_uploads';++const AGENT_GRANTS = 'mh_agent_grants';// ── Files ───────────────────────────────────────────────────────────────────────────────────────────────────────@@@ -126,6 +127,29 @@ const countDownload = (s, slug) => s.db.exec(`UPDATE ${SHARES} SET downloads = dconst deleteSharesForFile = (s, fileId) => s.db.exec(`DELETE FROM ${SHARES} WHERE file_id = $1`, [fileId]);const deleteSharesForFolder = (s, folderId) => s.db.exec(`DELETE FROM ${SHARES} WHERE folder_id = $1`, [folderId]);++// ── Agent grants ──────────────────────────────────────────────────────────────────────────────────────────────++++async function insertAgentGrant(s, row) {++ await s.db.query(++ `INSERT INTO ${AGENT_GRANTS} (id, owner, file_id, agent_subject, created_at, expires_at)++ VALUES ($1, $2, $3, $4, $5, $6)`,++ [row.id, row.owner, row.file_id, row.agent_subject, row.created_at, row.expires_at]);++ return getAgentGrant(s, row.id);++}++++const getAgentGrant = (s, id) => s.db.maybe(`SELECT * FROM ${AGENT_GRANTS} WHERE id = $1`, [id]);++const listAgentGrants = (s, owner, fileId = null) => s.db.many(++ `SELECT g.*, f.name AS file_name FROM ${AGENT_GRANTS} g JOIN ${FILES} f ON f.id = g.file_id++ WHERE g.owner = $1 AND ($2::text IS NULL OR g.file_id = $2) ORDER BY g.created_at DESC`, [owner, fileId]);++const activeAgentGrant = (s, fileId, agentSubject, nowIso) => s.db.maybe(++ `SELECT g.* FROM ${AGENT_GRANTS} g JOIN ${FILES} f ON f.id = g.file_id++ WHERE g.file_id = $1 AND g.agent_subject = $2 AND g.revoked_at IS NULL AND g.expires_at > $3++ AND f.deleted_at IS NULL AND f.binned_at IS NULL`, [fileId, agentSubject, nowIso]);++const revokeAgentGrant = (s, id, owner, at) => s.db.exec(++ `UPDATE ${AGENT_GRANTS} SET revoked_at = $3 WHERE id = $1 AND owner = $2 AND revoked_at IS NULL`, [id, owner, at]);++const revokeGrantsForFile = (s, fileId, at) => s.db.exec(++ `UPDATE ${AGENT_GRANTS} SET revoked_at = $2 WHERE file_id = $1 AND revoked_at IS NULL`, [fileId, at]);++// ── Reports ─────────────────────────────────────────────────────────────────────────────────────────────────────/** Record a report. The primary key is the truth: one per person per share, raced or not. */@@@ -200,6 +224,7 @@ module.exports = {insertFolder, getFolder, listFolders, countFolders, deleteFolder, renameFolder, folderInUse,insertShare, getShare, listShares, countLiveShares, revokeShare, suspendShare, restoreShare, countDownload,deleteSharesForFile, deleteSharesForFolder,++ insertAgentGrant, getAgentGrant, listAgentGrants, activeAgentGrant, revokeAgentGrant, revokeGrantsForFile,addReport, reportsFor, reportCount, bumpReportCount, staffQueue, staffCounts,bumpUploads, uploadsToday, usageFor,insertUpload, getUpload, setUploadFile, dropUpload,@@@ -105,7 +105,7 @@ function when(iso) {/** The thumbnail of a file: its own picture when it is an image, else a tile in its kind's colour with its icon. */function thumb(row, { big = false } = {}) {const kind = kindOf(row);-- if (rules.isPreviewable(row.content_type)) {++ if (!row.source && rules.isPreviewable(row.content_type)) {return html`<span class="dr-thumb is-pic" data-kind="${kind}"><img src="/files/${row.id}/preview?w=${big ? 640 : 96}" alt="" loading="lazy" decoding="async"></span>`;}const ext = extOf(row.name);@@@ -137,6 +137,13 @@ ${thumb(row, { big: true })}<form method="post" action="/files/${row.id}/restore"><button class="dr-btn is-quiet" type="submit">${icon('restore')}Restore</button></form><form method="post" action="/files/${row.id}/purge" data-confirm="Delete “${row.name}” forever? Its bytes are deleted from OpenVibe.Media and this cannot be undone."><button class="dr-btn is-quiet is-danger" type="submit">${icon('trash')}Delete forever</button></form></div></li>`;++ }++ if (row.source) {++ return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}">++<a class="dr-file-open" href="${row.source_url}">${thumb(row, { big: true })}++<span class="dr-file-text"><span class="dr-name">${row.name}</span>++<span class="dr-meta"><span>${rules.formatBytes(row.size)}</span><span>${when(row.created_at)}</span><span class="dr-where">${row.source_label}</span></span></span></a>++<a class="dr-btn is-quiet" href="${row.source_url}">Open in ${row.source_label.replace('OpenVibe.', '')}</a></li>`;}return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}"><a class="dr-file-open" href="/files/${row.id}">${thumb(row, { big: true })}@@@ -29,6 +29,8 @@* apart from the rate limiter's `rate_limited`, and answered before anything is written.*/const express = require('express');++const { Readable } = require('stream');++const { pipeline } = require('stream/promises');const contracts = require('openvibe-contracts');const { asyncRouter } = require('./router');const { sameOrigin } = require('./principal');@@@ -56,7 +58,7 @@ function readBody(req, cap) {}function createApi(ctx) {-- const { config, s, principal, limits, drive, uploads, syncKeys, drops } = ctx;++ const { config, s, principal, limits, drive, uploads, syncKeys, drops, media } = ctx;const r = asyncRouter();const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });@@@ -283,6 +285,56 @@ function createApi(ctx) {return res.json({ file: wireFile(out.file) });});++ // A person grants one agent access to one file; the read capability never expands that grant to a drive listing.++ r.get('/agent-grants', person, limits.reads('media-hub.file.read'), async (req, res) => {++ const fileId = req.query.file_id ? String(req.query.file_id) : null;++ if (fileId && !rules.isFileId(fileId)) return problem(req, res, 422, 'file.invalid', 'file_id must be a file id.');++ return res.json({ grants: await drive.listAgentGrants(personOf(req), fileId) });++ });++++ r.post('/agent-grants', person, limits.budget('media-hub.file.grant'), async (req, res) => {++ const b = req.body || {};++ const fileId = String(b.file_id || '');++ if (!rules.isFileId(fileId)) return problem(req, res, 422, 'file.invalid', 'file_id must be a file id.');++ const out = await drive.createAgentGrant(personOf(req), { fileId, agentSubject: b.agent_subject, hours: b.hours });++ if (out.error) return problem(req, res, out.error === 'file.not_found' ? 404 : 422, out.error, 'Choose one of your files, a valid agent subject, and an expiry from 1 to 720 hours.');++ res.set('Location', `/api/v1/agent-grants/${out.grant.id}`);++ return res.status(201).json({ grant: out.grant });++ });++++ r.delete('/agent-grants/:id', person, async (req, res) => {++ const out = await drive.revokeAgentGrant(personOf(req), String(req.params.id || ''));++ if (out.error) return problem(req, res, 404, 'grant.not_found', 'No such grant.');++ return res.status(204).end();++ });++++ // Link workers use this read-only endpoint for job inputs. Both the capability and the owner's file grant are++ // required. Stream through Media so a provider URL never becomes the agent's durable credential.++ r.get('/agent/files/:id/content', principal.requireCapability('download.file.read'), limits.reads('media-hub.file.read'), async (req, res) => {++ const caller = req.principal;++ if (!caller || caller.kind !== 'app' || !/^agent:agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(caller.requester || '')) {++ return problem(req, res, 403, 'agent.required', 'This endpoint is for an agent with download.file.read.');++ }++ const fileId = String(req.params.id || '');++ const row = rules.isFileId(fileId) ? await drive.agentFile(fileId, caller.requester) : null;++ if (!row) {++ return problem(req, res, 404, 'grant.not_found', 'No active grant for this file.');++ }++ const result = await media.bytes(row.object_id, { range: req.get('range') || null });++ if (!result.ok) return problem(req, res, result.status || 502, result.code || 'media.unreachable', result.detail || 'OpenVibe.Media could not read this file.');++ const upstream = result.res;++ res.status(result.status);++ res.set('Content-Type', row.content_type || 'application/octet-stream');++ res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(row.name)}`);++ res.set('Accept-Ranges', 'bytes');++ for (const header of ['content-length', 'content-range', 'etag', 'last-modified']) {++ const value = upstream.headers.get(header);++ if (value) res.set(header, value);++ }++ if (!upstream.body) return res.end();++ await pipeline(Readable.fromWeb(upstream.body), res);++ });++// ── Folders ─────────────────────────────────────────────r.get('/folders', person, limits.reads('media-hub.folder.read'), async (req, res) => {const rows = await drive.listFolders(personOf(req));@@@ -44,6 +44,7 @@ const BUDGETS = {'media-hub.upload.part': { minute: 300, hour: 5_000 },'media-hub.upload.complete': { minute: 30, hour: 300 },'media-hub.folder.create': { minute: 20, hour: 120 },++ 'media-hub.file.grant': { minute: 10, hour: 60 },'media-hub.sync_key.create': { minute: 5, hour: 20 },// A drop (an upload without an account) started per address: the per-day count and bytes are the drop's own caps.'media-hub.drop.start': { minute: 10, hour: 40 },@@@ -108,6 +108,30 @@ route('post', '/files/{id}/restore', {responses: { 200: json('The restored file', { type: 'object', properties: { file: ref('File') } }), ...errors([401, 403, 404, 429]) },});++// ── Agent file grants ─────────────────────────────────────────────────────────────────────────++route('get', '/agent-grants', {++ operationId: 'listAgentGrants', summary: 'List your file grants', tags: ['agent grants'], security: PERSON,++ parameters: [query('file_id', str(), 'Limit the list to one of your files')],++ responses: { 200: json('Your grants', { type: 'object', properties: { grants: arrayOf({ type: 'object' }) } }), ...errors([401, 429]) },++});++route('post', '/agent-grants', {++ operationId: 'createAgentGrant', summary: 'Give one agent read access to one file', tags: ['agent grants'], security: PERSON,++ description: 'The grant is bound to one agent subject and expires after 1 to 720 hours. The agent also needs the download.file.read capability.',++ requestBody: body({ type: 'object', required: ['file_id', 'agent_subject'], properties: { file_id: str(), agent_subject: str(), hours: integer({ minimum: 1, maximum: 720, default: 24 }) } }),++ responses: { 201: json('The grant', { type: 'object', properties: { grant: { type: 'object' } } }), ...errors([401, 404, 422, 429]) },++});++route('delete', '/agent-grants/{id}', {++ operationId: 'revokeAgentGrant', summary: 'Revoke an agent file grant', tags: ['agent grants'], security: PERSON,++ parameters: [path('id', str())],++ responses: { 204: { description: 'Revoked' }, ...errors([401, 404, 429]) },++});++route('get', '/agent/files/{id}/content', {++ operationId: 'readGrantedAgentFile', summary: 'Read a file granted to this agent', tags: ['agent grants'], security: [{ bearer: [] }],++ description: 'Requires the download.file.read capability and an active grant from the file owner. Supports Range requests; the response streams bytes without exposing a provider URL.',++ parameters: [path('id', str()), { name: 'Range', in: 'header', required: false, schema: str() }],++ responses: { 200: { description: 'File bytes' }, 206: { description: 'Requested byte range' }, ...errors([401, 403, 404, 416, 429, 502]) },++});++// ── Folders ────────────────────────────────────────────────────────────────────────────────────route('get', '/folders', {operationId: 'listFolders', summary: 'Your folders', tags: ['folders'], security: PERSON,@@@ -340,7 +340,11 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}if (sort === 'name') folders.sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' }));else if (sort === 'new') folders.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));else if (sort === 'old') folders.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));-- const files = sortRows(q ? await drive.searchFiles(owner, q, { limit: 200 }) : await drive.listFiles(owner, { folderId: here, limit: 200 }), sort);++ const [ownFiles, sourceFiles] = await Promise.all([++ q ? drive.searchFiles(owner, q, { limit: 200 }) : drive.listFiles(owner, { folderId: here, limit: 200 }),++ drive.listSourceFiles(owner, { query: q, include: Boolean(q || !here) }),++ ]);++ const files = sortRows([...ownFiles, ...sourceFiles], sort);const liveShares = (await drive.listShares(owner)).filter((sh) => drive.stateOf(sh, s.iso()) === 'live');const showShares = !q && !folder && liveShares.length > 0;const shareNames = new Map();@@@ -442,6 +446,7 @@ ${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.ficonst row = await drive.ownFile(owner, String(req.params.id || ''));if (!row) return notFoundFile(req, res);const shares = (await drive.listShares(owner)).filter((sh) => sh.file_id === row.id);++ const agentGrants = await drive.listAgentGrants(owner, row.id);const allFolders = await drive.listFolders(owner);const labels = folderLabels(allFolders);const path = row.folder_id ? await drive.folderPath(owner, row.folder_id) : [];@@@ -486,6 +491,15 @@ ${shares.length ? html`<h3 class="fv-h3">Links to this file</h3><ul class="dr-li${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-copy="${config.baseUrl}/s/${sh.slug}" hidden>Copy link</button><form method="post" action="/shares/${sh.slug}/revoke"><input type="hidden" name="from" value="${row.id}"><button class="dr-btn is-quiet" type="submit">Revoke</button></form>` : ''}</li>`; })}</ul>` : ''}</section>++<section class="fv-card" id="agent-access" aria-labelledby="h-agent-access"><h2 id="h-agent-access">Give an agent access</h2>++<p>Read access to this file only. The grant expires automatically and you can revoke it here. The agent must already have the <code>download.file.read</code> capability.</p>++<form class="fv-row" method="post" action="/files/${row.id}/agent-grants"><label for="agent-subject">Agent subject</label><input id="agent-subject" name="agent_subject" type="text" placeholder="agent:agt_…" required><label for="agent-hours">Hours</label><input id="agent-hours" name="hours" type="number" min="1" max="720" value="24" required><button class="dr-btn" type="submit">Grant read access</button></form>++${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul class="dr-links">${agentGrants.map((grant) => {++ const live = !grant.revoked_at && grant.expires_at > s.iso();++ return html`<li class="dr-link"><span class="dr-link-main"><code>${grant.agent_subject}</code><small>${live ? 'Read access until' : grant.revoked_at ? 'Revoked' : 'Expired'} ${live ? time(grant.expires_at) : ''}</small></span>${live ? html`<form method="post" action="/agent-grants/${grant.id}/revoke"><input type="hidden" name="from" value="${row.id}"><button class="dr-btn is-quiet" type="submit">Revoke</button></form>` : ''}</li>`;++ })}</ul>` : ''}++</section>++<section class="fv-card" id="move" aria-labelledby="h-move"><h2 id="h-move">${ui.icon('move')}Move</h2><form class="fv-row" method="post" action="/files/${row.id}/move"><label class="sr-only" for="move-to">Move to</label><select id="move-to" name="to"><option value=""${!row.folder_id ? raw(' selected') : ''}>My files (the top)</option>${choices.map(([id, label]) => html`<option value="${id}"${id === row.folder_id ? raw(' selected') : ''}>${label}</option>`)}</select>@@@ -624,6 +638,29 @@ ${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-cop});});++ r.post('/files/:id/agent-grants', mustSignIn('/files'), budgeted('media-hub.file.grant'), async (req, res) => {++ if (!sameSite(req, res, '/files')) return undefined;++ const owner = requesterOf(req);++ const out = await drive.createAgentGrant(owner, {++ fileId: String(req.params.id || ''),++ agentSubject: String(req.body.agent_subject || '').trim(),++ hours: req.body.hours,++ });++ if (out.error) return page(req, res, {++ title: 'Agent access not granted', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }],++ body: html`<h1>Agent access not granted</h1><p>${out.error === 'file.not_found' ? 'No such file.' : 'Use an agent subject such as agent:agt_… and an expiry from 1 to 720 hours.'}</p><p><a href="/files/${req.params.id}#agent-access">Back to this file</a></p>`,++ }, 422);++ return res.redirect(303, `/files/${req.params.id}#agent-access`);++ });++++ r.post('/agent-grants/:id/revoke', mustSignIn('/files'), async (req, res) => {++ if (!sameSite(req, res, '/files')) return undefined;++ const out = await drive.revokeAgentGrant(requesterOf(req), String(req.params.id || ''));++ const from = String(req.body.from || '');++ if (!out.error && rules.isFileId(from) && out.grant.file_id === from) return res.redirect(303, `/files/${from}#agent-access`);++ return res.redirect(303, '/files');++ });++r.post('/folders', mustSignIn('/files'), budgeted('media-hub.folder.create'), async (req, res) => {if (!sameSite(req, res, '/files')) return undefined;const { folder } = await folderOf(req);@@@ -27,7 +27,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;// The product fills this in: one entry per capability its routes name, in openvibe-contracts// manifests/capabilities/<media-hub>.*. requireCapability('<name>') refuses a name that is not listed here, so a// route can never be guarded by a capability the service does not declare.--const CAPABILITIES = ['video.playlist.read', 'video.playlist.manage'];++const CAPABILITIES = ['video.playlist.read', 'video.playlist.manage', 'download.file.read'];function decodePayload(token) {const parts = String(token || '').split('.');nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++++/** Per-file agent access: an owner grants one agent a bounded read, range reads stream from Media, and revocation wins. */++const assert = require('assert');++const crypto = require('crypto');++const { ids, serviceAuth } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const { bytes, upload, SAME } = require('./helpers/drive');++++(async () => {++ const t = await boot();++ const owner = t.network.addUser('owner');++ const fileBytes = bytes(512, 'granted file');++ const file = (await upload(t, owner, fileBytes, { name: 'agent-input.bin' })).json().file;++ const agentSubject = `agent:${ids.newId('agent')}`;++ const projectId = ids.newId('project');++ const hostId = ids.newId('app');++ const tokenFor = (caps) => serviceAuth.signServiceToken({++ iss: t.network.url, sub: agentSubject, actor_type: 'agent', aud: ['openvibe.download'],++ project_id: projectId, env: 'production', ns: [projectId], on_behalf_of: owner.subject, act: { sub: `app:${hostId}` },++ cap: caps, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600,++ jti: `tok_${crypto.randomBytes(8).toString('hex')}`,++ }, t.network.privatePem);++ const agentBearer = tokenFor(['download.file.read']);++++ await check('a capability alone cannot list or read the owner drive', async () => {++ assert.strictEqual((await t.get('/api/v1/files', { bearer: agentBearer })).status, 403);++ assert.strictEqual((await t.get(`/api/v1/agent/files/${file.id}/content`, { bearer: agentBearer })).status, 404);++ });++++ await check('the owner grants a single file, range reads work, and revocation takes effect', async () => {++ const created = await t.get('/api/v1/agent-grants', {++ as: owner, headers: SAME, json: { file_id: file.id, agent_subject: agentSubject, hours: 24 },++ });++ assert.strictEqual(created.status, 201, created.text);++ const grant = created.json().grant;++ assert.strictEqual(grant.agent_subject, agentSubject);++ assert.strictEqual(grant.file_id, file.id);++++ const range = await t.get(`/api/v1/agent/files/${file.id}/content`, {++ bearer: agentBearer, headers: { range: 'bytes=8-23' },++ });++ assert.strictEqual(range.status, 206, `${range.text}\n${t.logs()}`);++ assert.deepStrictEqual(range.buffer, fileBytes.subarray(8, 24));++ assert.strictEqual(range.headers.get('content-range'), `bytes 8-23/${fileBytes.length}`);++++ const list = await t.get('/api/v1/agent-grants', { as: owner });++ assert.ok(list.json().grants.some((g) => g.id === grant.id));++ assert.strictEqual((await t.get(`/api/v1/agent-grants/${grant.id}`, { as: owner, method: 'DELETE', headers: SAME })).status, 204);++ assert.strictEqual((await t.get(`/api/v1/agent/files/${file.id}/content`, { bearer: agentBearer })).status, 404);++ });++++ await check('a non-owner cannot grant access to another person\'s file', async () => {++ const stranger = t.network.addUser('stranger');++ const r = await t.get('/api/v1/agent-grants', {++ as: stranger, headers: SAME, json: { file_id: file.id, agent_subject: agentSubject },++ });++ assert.strictEqual(r.status, 404);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exitCode = 1; });@@@ -22,7 +22,7 @@ const READ = 'media-hub.api.read';await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [-- 'media-hub.drop.start', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',++ 'media-hub.drop.start', 'media-hub.file.grant', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write','media-hub.share.create', 'media-hub.studio.publish', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create','media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start','pics.album.create', 'pics.image.describe', 'pics.image.upload', 'pics.image.variant', 'pics.image.write', 'pics.report.create',nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++/** The drive's root and search results include a person's read-only Pics and Video records. */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const owner = `user:${kim.subject}`;++ const stamp = '2026-11-10T10:00:00.000Z';++ await t.ctx.s.db.exec(++ `INSERT INTO pics_images (id, slug, owner, title, object_id, mime_type, ext, width, height, bytes, sha256, source_sha256, dhash, created_at, updated_at)++ VALUES ('pic_01J0000000000000000000000A', 'AbCd1234', $1, 'Summer lake', 'med_pic_test', 'image/jpeg', 'jpg', 640, 480, 1234, 'a', 'b', '0123456789abcdef', $2, $2)`,++ [owner, stamp]);++ await t.ctx.s.db.exec(++ `INSERT INTO video_uploads (id, owner, title, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, status, state, created_at, updated_at)++ VALUES ('vup_01J0000000000000000000000B', $1, 'Lake walk', 'private', 77, 'video/mp4', 5678, 100, 1, 'ready', 'active', $2, $2)`,++ [owner, stamp]);++ await t.ctx.s.db.exec(++ `INSERT INTO video_uploads (id, owner, title, visibility, content_type, size_bytes, part_size, parts_expected, status, state, created_at, updated_at)++ VALUES ('vup_01J0000000000000000000000C', $1, 'Still uploading', 'private', 'video/mp4', 9, 9, 1, 'uploading', 'active', $2, $2)`,++ [owner, stamp]);++++ await check('root includes own Pics and ready Video records with source links', async () => {++ const page = await t.get('/files', { as: kim });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Summer lake') && page.text.includes('Open in Pics'));++ assert.ok(page.text.includes('Lake walk') && page.text.includes('Open in Video'));++ assert.ok(!page.text.includes('Still uploading'));++ });++ await check('search includes matching source records and excludes other owners', async () => {++ const page = await t.get('/files?q=lake', { as: kim });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Summer lake') && page.text.includes('Lake walk'));++ assert.ok(!page.text.includes('Still uploading'));++ });++ await done(t);++})().catch((e) => { console.error(e); process.exitCode = 1; });
@@ -82,7 +82,13 @@ The service manifest (openvibe-contracts, `manifests/services/media-hub.json`) l
@@ -82,7 +82,13 @@ The service manifest (openvibe-contracts, `manifests/services/media-hub.json`) l
The two playlist capabilities are registered in `server/http/principal.js` and guarded on the video host's API
The two playlist capabilities are registered in `server/http/principal.js` and guarded on the video host's API
(see **Playlist API**): `video.playlist.read` lets an app, agent or service list and read the playlists it owns and
(see **Playlist API**): `video.playlist.read` lets an app, agent or service list and read the playlists it owns and
any public or unlisted playlist by id, and `video.playlist.manage` lets it create, edit, reorder and delete its own.
any public or unlisted playlist by id, and `video.playlist.manage` lets it create, edit, reorder and delete its own.
-Every other `/api/v1` route still requires a signed-in person, never an app, agent or service token.
+`download.file.read` is for agent tokens only, and reads one file only when its owner created an active grant for that
+agent. A capability never lists the drive or replaces the per-file grant. Every other drive write still requires a
+signed-in person (or that person's scoped sync key).
+
+The signed-in `/files` root and search also show that person's Pics images and ready Video uploads as read-only
+entries. Their bytes and lifecycle remain with Pics and Video; opening an entry goes to its owning service. Those
+source entries are not yet eligible for Download agent grants, which apply to files uploaded into the drive.
On other services it calls:
On other services it calls:
@@ -406,12 +412,15 @@ local copy is gone. It authenticates with a **sync key** made on `/sync` (`OPENV
@@ -406,12 +412,15 @@ local copy is gone. It authenticates with a **sync key** made on `/sync` (`OPENV
| `GET /files`, `POST /files`, `GET /files/:id`, `DELETE /files/:id` | your files |
| `GET /files`, `POST /files`, `GET /files/:id`, `DELETE /files/:id` | your files |
| `GET /folders`, `POST /folders` | your folders |
| `GET /folders`, `POST /folders` | your folders |
| `POST /shares`, `DELETE /shares/:slug` | make (`{ file_id \| folder_id, hours, usernames? }`) and revoke share links |
| `POST /shares`, `DELETE /shares/:slug` | make (`{ file_id \| folder_id, hours, usernames? }`) and revoke share links |
+| `GET /agent-grants`, `POST /agent-grants`, `DELETE /agent-grants/:id` | list, make and revoke a read-only grant for one agent and one file; grants expire after 1–720 hours |
+| `GET /agent/files/:id/content` | agent-only `download.file.read`; streams the granted file with byte-range support |
| `GET /uploads/:id`, `PUT /uploads/:id/parts/:n`, `POST /uploads/:id/complete`, `DELETE /uploads/:id` | resumable uploads |
| `GET /uploads/:id`, `PUT /uploads/:id/parts/:n`, `POST /uploads/:id/complete`, `DELETE /uploads/:id` | resumable uploads |
| `GET /usage` | your usage against the limits |
| `GET /usage` | your usage against the limits |
On the video host (openvibe.video), the playlist API accepts a person Bearer token, an app/agent/service token
On the video host (openvibe.video), the playlist API accepts a person Bearer token, an app/agent/service token
-with the right capability, or (for public reads) no token — see **Playlist API** below. The drive's `/api/v1`
-routes are unchanged and remain a signed-in person only.
+with the right capability, or (for public reads) no token — see **Playlist API** below. On the drive API, the
+agent file-content route requires both an agent token with `download.file.read` and that agent's active per-file grant.
+Other drive routes remain person-only, except for the owner's scoped sync key.
## Playlist API
## Playlist API
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+-- phase: expand
+-- Read-only agent access to a person's explicitly selected files. A capability is necessary to use the read route,
+-- but each row is also bound to one file, one owner and one agent subject; grants can be revoked or expire.
+CREATE TABLE mh_agent_grants (
+ id text COLLATE "C" PRIMARY KEY, -- agr_<ULID>
+ owner text COLLATE "C" NOT NULL, -- user:usr_…
+ file_id text COLLATE "C" NOT NULL REFERENCES mh_files (id) ON DELETE CASCADE,
+ agent_subject text COLLATE "C" NOT NULL, -- agent:agt_…
+ created_at text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C" NOT NULL,
+ revoked_at text COLLATE "C"
+);
+
+CREATE INDEX mh_agent_grants_owner ON mh_agent_grants (owner, created_at DESC);
+CREATE INDEX mh_agent_grants_live ON mh_agent_grants (file_id, agent_subject, expires_at)
+ WHERE revoked_at IS NULL;
@@ -30,6 +30,7 @@ const TABLES = [
@@ -30,6 +30,7 @@ const TABLES = [
{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },
{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },
// Radio publications refer to drive files. Export and erase them before the file rows cascade.
// Radio publications refer to drive files. Export and erase them before the file rows cascade.
{ table: 'mh_radio_tracks', subject: 'owner', value: AS_REQUESTER, file: 'radio_tracks.json' },
{ table: 'mh_radio_tracks', subject: 'owner', value: AS_REQUESTER, file: 'radio_tracks.json' },
+ { table: 'mh_agent_grants', subject: 'owner', value: AS_REQUESTER, file: 'agent_grants.json' },
{ table: 'mh_files', subject: 'owner', value: AS_REQUESTER, file: 'files.json' },
{ table: 'mh_files', subject: 'owner', value: AS_REQUESTER, file: 'files.json' },
{ table: 'mh_folders', subject: 'owner', value: AS_REQUESTER, file: 'folders.json' },
{ table: 'mh_folders', subject: 'owner', value: AS_REQUESTER, file: 'folders.json' },
// A playlist's items have no owner column of their own; they are exported with extraExport below and erased
// A playlist's items have no owner column of their own; they are exported with extraExport below and erased
@@ -146,6 +146,7 @@ function createService({ s, media, identity, config, log = console }) {
@@ -146,6 +146,7 @@ function createService({ s, media, identity, config, log = console }) {
const row = await ownFile(owner, id);
const row = await ownFile(owner, id);
if (!row) return { error: 'file.not_found' };
if (!row) return { error: 'file.not_found' };
await store.deleteSharesForFile(s, id);
await store.deleteSharesForFile(s, id);
+ await store.revokeGrantsForFile(s, id, nowIso());
await store.binFile(s, id, nowIso());
await store.binFile(s, id, nowIso());
return { file: await store.getFile(s, id) };
return { file: await store.getFile(s, id) };
}
}
@@ -163,12 +164,43 @@ function createService({ s, media, identity, config, log = console }) {
@@ -163,12 +164,43 @@ function createService({ s, media, identity, config, log = console }) {
/** What deleteFile did before the bin: the shares go, the row is soft-deleted and Media is asked for the object. */
/** What deleteFile did before the bin: the shares go, the row is soft-deleted and Media is asked for the object. */
async function hardDelete(row) {
async function hardDelete(row) {
await store.deleteSharesForFile(s, row.id);
await store.deleteSharesForFile(s, row.id);
+ await store.revokeGrantsForFile(s, row.id, nowIso());
await store.softDeleteFile(s, row.id, nowIso());
await store.softDeleteFile(s, row.id, nowIso());
const removed = await media.remove(row.object_id);
const removed = await media.remove(row.object_id);
if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${row.object_id}: ${removed.status} ${removed.code}`);
if (!removed.ok) log.warn(`[MediaHub] Media refused to delete ${row.object_id}: ${removed.status} ${removed.code}`);
return { file: row, media: removed };
return { file: row, media: removed };
}
}
+ /** Give one agent read access to one of this person's files for a bounded period. */
+ async function createAgentGrant(owner, { fileId, agentSubject, hours = 24 }) {
+ const file = await ownFile(owner, fileId);
+ if (!file) return { error: 'file.not_found' };
+ if (!/^agent:agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(agentSubject || ''))) return { error: 'agent.invalid' };
+ const ttl = Number(hours);
+ if (!Number.isInteger(ttl) || ttl < 1 || ttl > 720) return { error: 'grant.invalid_expiry' };
+ const createdAt = nowIso();
+ return { grant: await store.insertAgentGrant(s, {
+ id: s.newId('agr'), owner, file_id: file.id, agent_subject: agentSubject,
+ created_at: createdAt, expires_at: new Date(Date.parse(createdAt) + ttl * 60 * 60 * 1000).toISOString(),
+ }) };
+ }
+
+ async function revokeAgentGrant(owner, id) {
+ const row = await store.getAgentGrant(s, id);
+ if (!row || row.owner !== owner) return { error: 'grant.not_found' };
+ await store.revokeAgentGrant(s, id, owner, nowIso());
+ return { grant: await store.getAgentGrant(s, id) };
+ }
+
+ const listAgentGrants = (owner, fileId = null) => store.listAgentGrants(s, owner, fileId);
+ const activeAgentGrant = (fileId, agentSubject) => store.activeAgentGrant(s, fileId, agentSubject, nowIso());
+ async function agentFile(fileId, agentSubject) {
+ const grant = await activeAgentGrant(fileId, agentSubject);
+ if (!grant) return null;
+ const row = await store.getFile(s, fileId);
+ return row && !row.deleted_at && !row.binned_at ? row : null;
+ }
+
/** Delete one binned file forever. Only a file already in the bin resolves here. */
/** Delete one binned file forever. Only a file already in the bin resolves here. */
async function purgeFile(owner, id) {
async function purgeFile(owner, id) {
const row = await ownBinnedFile(owner, id);
const row = await ownBinnedFile(owner, id);
@@ -414,6 +446,28 @@ function createService({ s, media, identity, config, log = console }) {
@@ -414,6 +446,28 @@ function createService({ s, media, identity, config, log = console }) {
// ── Reads the pages and the API both need (no SQL outside this module) ──
// ── Reads the pages and the API both need (no SQL outside this module) ──
const listFiles = (owner, opts) => store.listFiles(s, owner, opts);
const listFiles = (owner, opts) => store.listFiles(s, owner, opts);
+ /** Read-only source records from Pics and Video in the same MediaHub database; bytes remain with each product. */
+ async function listSourceFiles(owner, { query = '', include = true } = {}) {
+ if (!include) return [];
+ const q = String(query || '').trim();
+ const like = q ? `%${q.replace(/[\\%_]/g, '\\$&')}%` : null;
+ const [pictures, videos] = await Promise.all([
+ s.db.many(
+ `SELECT id, slug, title, object_id, mime_type AS content_type, bytes AS size, created_at
+ FROM pics_images WHERE owner = $1 AND state IN ('active', 'hidden')
+ AND ($2::text IS NULL OR title ILIKE $2 ESCAPE '\\' OR slug ILIKE $2 ESCAPE '\\')
+ ORDER BY id DESC LIMIT 200`, [owner, like]),
+ s.db.many(
+ `SELECT id, title, media_vod_id, content_type, size_bytes AS size, created_at
+ FROM video_uploads WHERE owner = $1 AND state <> 'removed' AND status = 'ready' AND media_vod_id IS NOT NULL
+ AND ($2::text IS NULL OR title ILIKE $2 ESCAPE '\\' OR id ILIKE $2 ESCAPE '\\')
+ ORDER BY id DESC LIMIT 200`, [owner, like]),
+ ]);
+ return [
+ ...pictures.map((row) => ({ ...row, name: String(row.title || '').trim() || `Picture ${row.slug}`, source: 'pics', source_label: 'OpenVibe.Pics', source_url: `${config.pics.url}/i/${encodeURIComponent(row.slug)}` })),
+ ...videos.map((row) => ({ ...row, name: String(row.title || '').trim() || 'Video', source: 'video', source_label: 'OpenVibe.Video', source_url: `${config.video.url}/u/${encodeURIComponent(row.id)}` })),
+ ];
+ }
const listBinned = (owner, opts) => store.listBinned(s, owner, opts);
const listBinned = (owner, opts) => store.listBinned(s, owner, opts);
const binCount = (owner) => store.countBinned(s, owner);
const binCount = (owner) => store.countBinned(s, owner);
const listFolders = (owner) => store.listFolders(s, owner);
const listFolders = (owner) => store.listFolders(s, owner);
@@ -426,9 +480,10 @@ function createService({ s, media, identity, config, log = console }) {
@@ -426,9 +480,10 @@ function createService({ s, media, identity, config, log = console }) {
uploadAllowed, shareAllowed, usage, dayOf,
uploadAllowed, shareAllowed, usage, dayOf,
recordFile, ownFile, ownBinnedFile, deleteFile, restoreFile, purgeFile, purgeDue, emptyBin, updateFile,
recordFile, ownFile, ownBinnedFile, deleteFile, restoreFile, purgeFile, purgeDue, emptyBin, updateFile,
renameFile, moveFile, searchFiles,
renameFile, moveFile, searchFiles,
- listFiles, listBinned, binCount, listFolders, listShares, usageDays, shareBySlug, reportsFor,
+ listFiles, listSourceFiles, listBinned, binCount, listFolders, listShares, usageDays, shareBySlug, reportsFor,
ownFolder, createFolder, renameFolder, deleteFolder, folderPath,
ownFolder, createFolder, renameFolder, deleteFolder, folderPath,
createShare, ownShare, revokeShare,
createShare, ownShare, revokeShare,
+ createAgentGrant, revokeAgentGrant, listAgentGrants, activeAgentGrant, agentFile,
report, restoreShare, removeShared,
report, restoreShare, removeShared,
stateOf, allowedFor, parseAllow,
stateOf, allowedFor, parseAllow,
};
};
@@ -17,6 +17,7 @@ const SHARES = 'mh_shares';
@@ -17,6 +17,7 @@ const SHARES = 'mh_shares';
const REPORTS = 'mh_reports';
const REPORTS = 'mh_reports';
const USAGE = 'mh_usage';
const USAGE = 'mh_usage';
const UPLOADS = 'mh_uploads';
const UPLOADS = 'mh_uploads';
+const AGENT_GRANTS = 'mh_agent_grants';
// ── Files ───────────────────────────────────────────────────────────────────────────────────────────────────────
// ── Files ───────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -126,6 +127,29 @@ const countDownload = (s, slug) => s.db.exec(`UPDATE ${SHARES} SET downloads = d
@@ -126,6 +127,29 @@ const countDownload = (s, slug) => s.db.exec(`UPDATE ${SHARES} SET downloads = d
const deleteSharesForFile = (s, fileId) => s.db.exec(`DELETE FROM ${SHARES} WHERE file_id = $1`, [fileId]);
const deleteSharesForFile = (s, fileId) => s.db.exec(`DELETE FROM ${SHARES} WHERE file_id = $1`, [fileId]);
const deleteSharesForFolder = (s, folderId) => s.db.exec(`DELETE FROM ${SHARES} WHERE folder_id = $1`, [folderId]);
const deleteSharesForFolder = (s, folderId) => s.db.exec(`DELETE FROM ${SHARES} WHERE folder_id = $1`, [folderId]);
+// ── Agent grants ──────────────────────────────────────────────────────────────────────────────────────────────
+
+async function insertAgentGrant(s, row) {
+ await s.db.query(
+ `INSERT INTO ${AGENT_GRANTS} (id, owner, file_id, agent_subject, created_at, expires_at)
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ [row.id, row.owner, row.file_id, row.agent_subject, row.created_at, row.expires_at]);
+ return getAgentGrant(s, row.id);
+}
+
+const getAgentGrant = (s, id) => s.db.maybe(`SELECT * FROM ${AGENT_GRANTS} WHERE id = $1`, [id]);
+const listAgentGrants = (s, owner, fileId = null) => s.db.many(
+ `SELECT g.*, f.name AS file_name FROM ${AGENT_GRANTS} g JOIN ${FILES} f ON f.id = g.file_id
+ WHERE g.owner = $1 AND ($2::text IS NULL OR g.file_id = $2) ORDER BY g.created_at DESC`, [owner, fileId]);
+const activeAgentGrant = (s, fileId, agentSubject, nowIso) => s.db.maybe(
+ `SELECT g.* FROM ${AGENT_GRANTS} g JOIN ${FILES} f ON f.id = g.file_id
+ WHERE g.file_id = $1 AND g.agent_subject = $2 AND g.revoked_at IS NULL AND g.expires_at > $3
+ AND f.deleted_at IS NULL AND f.binned_at IS NULL`, [fileId, agentSubject, nowIso]);
+const revokeAgentGrant = (s, id, owner, at) => s.db.exec(
+ `UPDATE ${AGENT_GRANTS} SET revoked_at = $3 WHERE id = $1 AND owner = $2 AND revoked_at IS NULL`, [id, owner, at]);
+const revokeGrantsForFile = (s, fileId, at) => s.db.exec(
+ `UPDATE ${AGENT_GRANTS} SET revoked_at = $2 WHERE file_id = $1 AND revoked_at IS NULL`, [fileId, at]);
+
// ── Reports ─────────────────────────────────────────────────────────────────────────────────────────────────────
// ── Reports ─────────────────────────────────────────────────────────────────────────────────────────────────────
/** Record a report. The primary key is the truth: one per person per share, raced or not. */
/** Record a report. The primary key is the truth: one per person per share, raced or not. */
@@ -200,6 +224,7 @@ module.exports = {
@@ -200,6 +224,7 @@ module.exports = {
insertFolder, getFolder, listFolders, countFolders, deleteFolder, renameFolder, folderInUse,
insertFolder, getFolder, listFolders, countFolders, deleteFolder, renameFolder, folderInUse,
insertShare, getShare, listShares, countLiveShares, revokeShare, suspendShare, restoreShare, countDownload,
insertShare, getShare, listShares, countLiveShares, revokeShare, suspendShare, restoreShare, countDownload,
deleteSharesForFile, deleteSharesForFolder,
deleteSharesForFile, deleteSharesForFolder,
+ insertAgentGrant, getAgentGrant, listAgentGrants, activeAgentGrant, revokeAgentGrant, revokeGrantsForFile,
addReport, reportsFor, reportCount, bumpReportCount, staffQueue, staffCounts,
addReport, reportsFor, reportCount, bumpReportCount, staffQueue, staffCounts,
bumpUploads, uploadsToday, usageFor,
bumpUploads, uploadsToday, usageFor,
insertUpload, getUpload, setUploadFile, dropUpload,
insertUpload, getUpload, setUploadFile, dropUpload,
@@ -105,7 +105,7 @@ function when(iso) {
@@ -105,7 +105,7 @@ function when(iso) {
/** The thumbnail of a file: its own picture when it is an image, else a tile in its kind's colour with its icon. */
/** The thumbnail of a file: its own picture when it is an image, else a tile in its kind's colour with its icon. */
function thumb(row, { big = false } = {}) {
function thumb(row, { big = false } = {}) {
const kind = kindOf(row);
const kind = kindOf(row);
- if (rules.isPreviewable(row.content_type)) {
+ if (!row.source && rules.isPreviewable(row.content_type)) {
return html`<span class="dr-thumb is-pic" data-kind="${kind}"><img src="/files/${row.id}/preview?w=${big ? 640 : 96}" alt="" loading="lazy" decoding="async"></span>`;
return html`<span class="dr-thumb is-pic" data-kind="${kind}"><img src="/files/${row.id}/preview?w=${big ? 640 : 96}" alt="" loading="lazy" decoding="async"></span>`;
}
}
const ext = extOf(row.name);
const ext = extOf(row.name);
@@ -137,6 +137,13 @@ ${thumb(row, { big: true })}
@@ -137,6 +137,13 @@ ${thumb(row, { big: true })}
<form method="post" action="/files/${row.id}/restore"><button class="dr-btn is-quiet" type="submit">${icon('restore')}Restore</button></form>
<form method="post" action="/files/${row.id}/restore"><button class="dr-btn is-quiet" type="submit">${icon('restore')}Restore</button></form>
<form method="post" action="/files/${row.id}/purge" data-confirm="Delete “${row.name}” forever? Its bytes are deleted from OpenVibe.Media and this cannot be undone."><button class="dr-btn is-quiet is-danger" type="submit">${icon('trash')}Delete forever</button></form>
<form method="post" action="/files/${row.id}/purge" data-confirm="Delete “${row.name}” forever? Its bytes are deleted from OpenVibe.Media and this cannot be undone."><button class="dr-btn is-quiet is-danger" type="submit">${icon('trash')}Delete forever</button></form>
</div></li>`;
</div></li>`;
+ }
+ if (row.source) {
+ return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}">
+<a class="dr-file-open" href="${row.source_url}">${thumb(row, { big: true })}
+<span class="dr-file-text"><span class="dr-name">${row.name}</span>
+<span class="dr-meta"><span>${rules.formatBytes(row.size)}</span><span>${when(row.created_at)}</span><span class="dr-where">${row.source_label}</span></span></span></a>
+<a class="dr-btn is-quiet" href="${row.source_url}">Open in ${row.source_label.replace('OpenVibe.', '')}</a></li>`;
}
}
return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}">
return html`<li class="dr-file" data-name="${String(row.name).toLowerCase()}" data-time="${row.created_at || ''}" data-size="${Number(row.size) || 0}" data-kind="${kindOf(row)}">
<a class="dr-file-open" href="/files/${row.id}">${thumb(row, { big: true })}
<a class="dr-file-open" href="/files/${row.id}">${thumb(row, { big: true })}
@@ -29,6 +29,8 @@
@@ -29,6 +29,8 @@
* apart from the rate limiter's `rate_limited`, and answered before anything is written.
* apart from the rate limiter's `rate_limited`, and answered before anything is written.
*/
*/
const express = require('express');
const express = require('express');
+const { Readable } = require('stream');
+const { pipeline } = require('stream/promises');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
@@ -56,7 +58,7 @@ function readBody(req, cap) {
@@ -56,7 +58,7 @@ function readBody(req, cap) {
}
}
function createApi(ctx) {
function createApi(ctx) {
- const { config, s, principal, limits, drive, uploads, syncKeys, drops } = ctx;
+ const { config, s, principal, limits, drive, uploads, syncKeys, drops, media } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
@@ -283,6 +285,56 @@ function createApi(ctx) {
@@ -283,6 +285,56 @@ function createApi(ctx) {
return res.json({ file: wireFile(out.file) });
return res.json({ file: wireFile(out.file) });
});
});
+ // A person grants one agent access to one file; the read capability never expands that grant to a drive listing.
+ r.get('/agent-grants', person, limits.reads('media-hub.file.read'), async (req, res) => {
+ const fileId = req.query.file_id ? String(req.query.file_id) : null;
+ if (fileId && !rules.isFileId(fileId)) return problem(req, res, 422, 'file.invalid', 'file_id must be a file id.');
+ return res.json({ grants: await drive.listAgentGrants(personOf(req), fileId) });
+ });
+
+ r.post('/agent-grants', person, limits.budget('media-hub.file.grant'), async (req, res) => {
+ const b = req.body || {};
+ const fileId = String(b.file_id || '');
+ if (!rules.isFileId(fileId)) return problem(req, res, 422, 'file.invalid', 'file_id must be a file id.');
+ const out = await drive.createAgentGrant(personOf(req), { fileId, agentSubject: b.agent_subject, hours: b.hours });
+ if (out.error) return problem(req, res, out.error === 'file.not_found' ? 404 : 422, out.error, 'Choose one of your files, a valid agent subject, and an expiry from 1 to 720 hours.');
+ res.set('Location', `/api/v1/agent-grants/${out.grant.id}`);
+ return res.status(201).json({ grant: out.grant });
+ });
+
+ r.delete('/agent-grants/:id', person, async (req, res) => {
+ const out = await drive.revokeAgentGrant(personOf(req), String(req.params.id || ''));
+ if (out.error) return problem(req, res, 404, 'grant.not_found', 'No such grant.');
+ return res.status(204).end();
+ });
+
+ // Link workers use this read-only endpoint for job inputs. Both the capability and the owner's file grant are
+ // required. Stream through Media so a provider URL never becomes the agent's durable credential.
+ r.get('/agent/files/:id/content', principal.requireCapability('download.file.read'), limits.reads('media-hub.file.read'), async (req, res) => {
+ const caller = req.principal;
+ if (!caller || caller.kind !== 'app' || !/^agent:agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(caller.requester || '')) {
+ return problem(req, res, 403, 'agent.required', 'This endpoint is for an agent with download.file.read.');
+ }
+ const fileId = String(req.params.id || '');
+ const row = rules.isFileId(fileId) ? await drive.agentFile(fileId, caller.requester) : null;
+ if (!row) {
+ return problem(req, res, 404, 'grant.not_found', 'No active grant for this file.');
+ }
+ const result = await media.bytes(row.object_id, { range: req.get('range') || null });
+ if (!result.ok) return problem(req, res, result.status || 502, result.code || 'media.unreachable', result.detail || 'OpenVibe.Media could not read this file.');
+ const upstream = result.res;
+ res.status(result.status);
+ res.set('Content-Type', row.content_type || 'application/octet-stream');
+ res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(row.name)}`);
+ res.set('Accept-Ranges', 'bytes');
+ for (const header of ['content-length', 'content-range', 'etag', 'last-modified']) {
+ const value = upstream.headers.get(header);
+ if (value) res.set(header, value);
+ }
+ if (!upstream.body) return res.end();
+ await pipeline(Readable.fromWeb(upstream.body), res);
+ });
+
// ── Folders ─────────────────────────────────────────────
// ── Folders ─────────────────────────────────────────────
r.get('/folders', person, limits.reads('media-hub.folder.read'), async (req, res) => {
r.get('/folders', person, limits.reads('media-hub.folder.read'), async (req, res) => {
const rows = await drive.listFolders(personOf(req));
const rows = await drive.listFolders(personOf(req));
@@ -44,6 +44,7 @@ const BUDGETS = {
@@ -44,6 +44,7 @@ const BUDGETS = {
'media-hub.upload.part': { minute: 300, hour: 5_000 },
'media-hub.upload.part': { minute: 300, hour: 5_000 },
'media-hub.upload.complete': { minute: 30, hour: 300 },
'media-hub.upload.complete': { minute: 30, hour: 300 },
'media-hub.folder.create': { minute: 20, hour: 120 },
'media-hub.folder.create': { minute: 20, hour: 120 },
+ 'media-hub.file.grant': { minute: 10, hour: 60 },
'media-hub.sync_key.create': { minute: 5, hour: 20 },
'media-hub.sync_key.create': { minute: 5, hour: 20 },
// A drop (an upload without an account) started per address: the per-day count and bytes are the drop's own caps.
// A drop (an upload without an account) started per address: the per-day count and bytes are the drop's own caps.
'media-hub.drop.start': { minute: 10, hour: 40 },
'media-hub.drop.start': { minute: 10, hour: 40 },
@@ -108,6 +108,30 @@ route('post', '/files/{id}/restore', {
@@ -108,6 +108,30 @@ route('post', '/files/{id}/restore', {
responses: { 200: json('The restored file', { type: 'object', properties: { file: ref('File') } }), ...errors([401, 403, 404, 429]) },
responses: { 200: json('The restored file', { type: 'object', properties: { file: ref('File') } }), ...errors([401, 403, 404, 429]) },
});
});
+// ── Agent file grants ─────────────────────────────────────────────────────────────────────────
+route('get', '/agent-grants', {
+ operationId: 'listAgentGrants', summary: 'List your file grants', tags: ['agent grants'], security: PERSON,
+ parameters: [query('file_id', str(), 'Limit the list to one of your files')],
+ responses: { 200: json('Your grants', { type: 'object', properties: { grants: arrayOf({ type: 'object' }) } }), ...errors([401, 429]) },
+});
+route('post', '/agent-grants', {
+ operationId: 'createAgentGrant', summary: 'Give one agent read access to one file', tags: ['agent grants'], security: PERSON,
+ description: 'The grant is bound to one agent subject and expires after 1 to 720 hours. The agent also needs the download.file.read capability.',
+ requestBody: body({ type: 'object', required: ['file_id', 'agent_subject'], properties: { file_id: str(), agent_subject: str(), hours: integer({ minimum: 1, maximum: 720, default: 24 }) } }),
+ responses: { 201: json('The grant', { type: 'object', properties: { grant: { type: 'object' } } }), ...errors([401, 404, 422, 429]) },
+});
+route('delete', '/agent-grants/{id}', {
+ operationId: 'revokeAgentGrant', summary: 'Revoke an agent file grant', tags: ['agent grants'], security: PERSON,
+ parameters: [path('id', str())],
+ responses: { 204: { description: 'Revoked' }, ...errors([401, 404, 429]) },
+});
+route('get', '/agent/files/{id}/content', {
+ operationId: 'readGrantedAgentFile', summary: 'Read a file granted to this agent', tags: ['agent grants'], security: [{ bearer: [] }],
+ description: 'Requires the download.file.read capability and an active grant from the file owner. Supports Range requests; the response streams bytes without exposing a provider URL.',
+ parameters: [path('id', str()), { name: 'Range', in: 'header', required: false, schema: str() }],
+ responses: { 200: { description: 'File bytes' }, 206: { description: 'Requested byte range' }, ...errors([401, 403, 404, 416, 429, 502]) },
+});
+
// ── Folders ────────────────────────────────────────────────────────────────────────────────────
// ── Folders ────────────────────────────────────────────────────────────────────────────────────
route('get', '/folders', {
route('get', '/folders', {
operationId: 'listFolders', summary: 'Your folders', tags: ['folders'], security: PERSON,
operationId: 'listFolders', summary: 'Your folders', tags: ['folders'], security: PERSON,
@@ -340,7 +340,11 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
@@ -340,7 +340,11 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
if (sort === 'name') folders.sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' }));
if (sort === 'name') folders.sort((a, b) => a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' }));
else if (sort === 'new') folders.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
else if (sort === 'new') folders.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
else if (sort === 'old') folders.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
else if (sort === 'old') folders.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
- const files = sortRows(q ? await drive.searchFiles(owner, q, { limit: 200 }) : await drive.listFiles(owner, { folderId: here, limit: 200 }), sort);
+ const [ownFiles, sourceFiles] = await Promise.all([
+ q ? drive.searchFiles(owner, q, { limit: 200 }) : drive.listFiles(owner, { folderId: here, limit: 200 }),
+ drive.listSourceFiles(owner, { query: q, include: Boolean(q || !here) }),
+ ]);
+ const files = sortRows([...ownFiles, ...sourceFiles], sort);
const liveShares = (await drive.listShares(owner)).filter((sh) => drive.stateOf(sh, s.iso()) === 'live');
const liveShares = (await drive.listShares(owner)).filter((sh) => drive.stateOf(sh, s.iso()) === 'live');
const showShares = !q && !folder && liveShares.length > 0;
const showShares = !q && !folder && liveShares.length > 0;
const shareNames = new Map();
const shareNames = new Map();
@@ -442,6 +446,7 @@ ${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.fi
@@ -442,6 +446,7 @@ ${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.fi
const row = await drive.ownFile(owner, String(req.params.id || ''));
const row = await drive.ownFile(owner, String(req.params.id || ''));
if (!row) return notFoundFile(req, res);
if (!row) return notFoundFile(req, res);
const shares = (await drive.listShares(owner)).filter((sh) => sh.file_id === row.id);
const shares = (await drive.listShares(owner)).filter((sh) => sh.file_id === row.id);
+ const agentGrants = await drive.listAgentGrants(owner, row.id);
const allFolders = await drive.listFolders(owner);
const allFolders = await drive.listFolders(owner);
const labels = folderLabels(allFolders);
const labels = folderLabels(allFolders);
const path = row.folder_id ? await drive.folderPath(owner, row.folder_id) : [];
const path = row.folder_id ? await drive.folderPath(owner, row.folder_id) : [];
@@ -486,6 +491,15 @@ ${shares.length ? html`<h3 class="fv-h3">Links to this file</h3><ul class="dr-li
@@ -486,6 +491,15 @@ ${shares.length ? html`<h3 class="fv-h3">Links to this file</h3><ul class="dr-li
${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-copy="${config.baseUrl}/s/${sh.slug}" hidden>Copy link</button><form method="post" action="/shares/${sh.slug}/revoke"><input type="hidden" name="from" value="${row.id}"><button class="dr-btn is-quiet" type="submit">Revoke</button></form>` : ''}</li>`; })}</ul>` : ''}
${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-copy="${config.baseUrl}/s/${sh.slug}" hidden>Copy link</button><form method="post" action="/shares/${sh.slug}/revoke"><input type="hidden" name="from" value="${row.id}"><button class="dr-btn is-quiet" type="submit">Revoke</button></form>` : ''}</li>`; })}</ul>` : ''}
</section>
</section>
+<section class="fv-card" id="agent-access" aria-labelledby="h-agent-access"><h2 id="h-agent-access">Give an agent access</h2>
+<p>Read access to this file only. The grant expires automatically and you can revoke it here. The agent must already have the <code>download.file.read</code> capability.</p>
+<form class="fv-row" method="post" action="/files/${row.id}/agent-grants"><label for="agent-subject">Agent subject</label><input id="agent-subject" name="agent_subject" type="text" placeholder="agent:agt_…" required><label for="agent-hours">Hours</label><input id="agent-hours" name="hours" type="number" min="1" max="720" value="24" required><button class="dr-btn" type="submit">Grant read access</button></form>
+${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul class="dr-links">${agentGrants.map((grant) => {
+ const live = !grant.revoked_at && grant.expires_at > s.iso();
+ return html`<li class="dr-link"><span class="dr-link-main"><code>${grant.agent_subject}</code><small>${live ? 'Read access until' : grant.revoked_at ? 'Revoked' : 'Expired'} ${live ? time(grant.expires_at) : ''}</small></span>${live ? html`<form method="post" action="/agent-grants/${grant.id}/revoke"><input type="hidden" name="from" value="${row.id}"><button class="dr-btn is-quiet" type="submit">Revoke</button></form>` : ''}</li>`;
+ })}</ul>` : ''}
+</section>
+
<section class="fv-card" id="move" aria-labelledby="h-move"><h2 id="h-move">${ui.icon('move')}Move</h2>
<section class="fv-card" id="move" aria-labelledby="h-move"><h2 id="h-move">${ui.icon('move')}Move</h2>
<form class="fv-row" method="post" action="/files/${row.id}/move"><label class="sr-only" for="move-to">Move to</label>
<form class="fv-row" method="post" action="/files/${row.id}/move"><label class="sr-only" for="move-to">Move to</label>
<select id="move-to" name="to"><option value=""${!row.folder_id ? raw(' selected') : ''}>My files (the top)</option>${choices.map(([id, label]) => html`<option value="${id}"${id === row.folder_id ? raw(' selected') : ''}>${label}</option>`)}</select>
<select id="move-to" name="to"><option value=""${!row.folder_id ? raw(' selected') : ''}>My files (the top)</option>${choices.map(([id, label]) => html`<option value="${id}"${id === row.folder_id ? raw(' selected') : ''}>${label}</option>`)}</select>
@@ -624,6 +638,29 @@ ${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-cop
@@ -624,6 +638,29 @@ ${state === 'live' ? html`<button class="dr-btn is-quiet" type="button" data-cop
});
});
});
});
+ r.post('/files/:id/agent-grants', mustSignIn('/files'), budgeted('media-hub.file.grant'), async (req, res) => {
+ if (!sameSite(req, res, '/files')) return undefined;
+ const owner = requesterOf(req);
+ const out = await drive.createAgentGrant(owner, {
+ fileId: String(req.params.id || ''),
+ agentSubject: String(req.body.agent_subject || '').trim(),
+ hours: req.body.hours,
+ });
+ if (out.error) return page(req, res, {
+ title: 'Agent access not granted', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }],
+ body: html`<h1>Agent access not granted</h1><p>${out.error === 'file.not_found' ? 'No such file.' : 'Use an agent subject such as agent:agt_… and an expiry from 1 to 720 hours.'}</p><p><a href="/files/${req.params.id}#agent-access">Back to this file</a></p>`,
+ }, 422);
+ return res.redirect(303, `/files/${req.params.id}#agent-access`);
+ });
+
+ r.post('/agent-grants/:id/revoke', mustSignIn('/files'), async (req, res) => {
+ if (!sameSite(req, res, '/files')) return undefined;
+ const out = await drive.revokeAgentGrant(requesterOf(req), String(req.params.id || ''));
+ const from = String(req.body.from || '');
+ if (!out.error && rules.isFileId(from) && out.grant.file_id === from) return res.redirect(303, `/files/${from}#agent-access`);
+ return res.redirect(303, '/files');
+ });
+
r.post('/folders', mustSignIn('/files'), budgeted('media-hub.folder.create'), async (req, res) => {
r.post('/folders', mustSignIn('/files'), budgeted('media-hub.folder.create'), async (req, res) => {
if (!sameSite(req, res, '/files')) return undefined;
if (!sameSite(req, res, '/files')) return undefined;
const { folder } = await folderOf(req);
const { folder } = await folderOf(req);
@@ -27,7 +27,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -27,7 +27,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
// The product fills this in: one entry per capability its routes name, in openvibe-contracts
// The product fills this in: one entry per capability its routes name, in openvibe-contracts
// manifests/capabilities/<media-hub>.*. requireCapability('<name>') refuses a name that is not listed here, so a
// manifests/capabilities/<media-hub>.*. requireCapability('<name>') refuses a name that is not listed here, so a
// route can never be guarded by a capability the service does not declare.
// route can never be guarded by a capability the service does not declare.
-const CAPABILITIES = ['video.playlist.read', 'video.playlist.manage'];
+const CAPABILITIES = ['video.playlist.read', 'video.playlist.manage', 'download.file.read'];
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+
+/** Per-file agent access: an owner grants one agent a bounded read, range reads stream from Media, and revocation wins. */
+const assert = require('assert');
+const crypto = require('crypto');
+const { ids, serviceAuth } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const { bytes, upload, SAME } = require('./helpers/drive');
+
+(async () => {
+ const t = await boot();
+ const owner = t.network.addUser('owner');
+ const fileBytes = bytes(512, 'granted file');
+ const file = (await upload(t, owner, fileBytes, { name: 'agent-input.bin' })).json().file;
+ const agentSubject = `agent:${ids.newId('agent')}`;
+ const projectId = ids.newId('project');
+ const hostId = ids.newId('app');
+ const tokenFor = (caps) => serviceAuth.signServiceToken({
+ iss: t.network.url, sub: agentSubject, actor_type: 'agent', aud: ['openvibe.download'],
+ project_id: projectId, env: 'production', ns: [projectId], on_behalf_of: owner.subject, act: { sub: `app:${hostId}` },
+ cap: caps, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600,
+ jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
+ }, t.network.privatePem);
+ const agentBearer = tokenFor(['download.file.read']);
+
+ await check('a capability alone cannot list or read the owner drive', async () => {
+ assert.strictEqual((await t.get('/api/v1/files', { bearer: agentBearer })).status, 403);
+ assert.strictEqual((await t.get(`/api/v1/agent/files/${file.id}/content`, { bearer: agentBearer })).status, 404);
+ });
+
+ await check('the owner grants a single file, range reads work, and revocation takes effect', async () => {
+ const created = await t.get('/api/v1/agent-grants', {
+ as: owner, headers: SAME, json: { file_id: file.id, agent_subject: agentSubject, hours: 24 },
+ });
+ assert.strictEqual(created.status, 201, created.text);
+ const grant = created.json().grant;
+ assert.strictEqual(grant.agent_subject, agentSubject);
+ assert.strictEqual(grant.file_id, file.id);
+
+ const range = await t.get(`/api/v1/agent/files/${file.id}/content`, {
+ bearer: agentBearer, headers: { range: 'bytes=8-23' },
+ });
+ assert.strictEqual(range.status, 206, `${range.text}\n${t.logs()}`);
+ assert.deepStrictEqual(range.buffer, fileBytes.subarray(8, 24));
+ assert.strictEqual(range.headers.get('content-range'), `bytes 8-23/${fileBytes.length}`);
+
+ const list = await t.get('/api/v1/agent-grants', { as: owner });
+ assert.ok(list.json().grants.some((g) => g.id === grant.id));
+ assert.strictEqual((await t.get(`/api/v1/agent-grants/${grant.id}`, { as: owner, method: 'DELETE', headers: SAME })).status, 204);
+ assert.strictEqual((await t.get(`/api/v1/agent/files/${file.id}/content`, { bearer: agentBearer })).status, 404);
+ });
+
+ await check('a non-owner cannot grant access to another person\'s file', async () => {
+ const stranger = t.network.addUser('stranger');
+ const r = await t.get('/api/v1/agent-grants', {
+ as: stranger, headers: SAME, json: { file_id: file.id, agent_subject: agentSubject },
+ });
+ assert.strictEqual(r.status, 404);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exitCode = 1; });
@@ -22,7 +22,7 @@ const READ = 'media-hub.api.read';
@@ -22,7 +22,7 @@ const READ = 'media-hub.api.read';
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [
assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [
- 'media-hub.drop.start', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
+ 'media-hub.drop.start', 'media-hub.file.grant', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
'media-hub.share.create', 'media-hub.studio.publish', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create',
'media-hub.share.create', 'media-hub.studio.publish', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
'pics.album.create', 'pics.image.describe', 'pics.image.upload', 'pics.image.variant', 'pics.image.write', 'pics.report.create',
'pics.album.create', 'pics.image.describe', 'pics.image.upload', 'pics.image.variant', 'pics.image.write', 'pics.report.create',
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+/** The drive's root and search results include a person's read-only Pics and Video records. */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const owner = `user:${kim.subject}`;
+ const stamp = '2026-11-10T10:00:00.000Z';
+ await t.ctx.s.db.exec(
+ `INSERT INTO pics_images (id, slug, owner, title, object_id, mime_type, ext, width, height, bytes, sha256, source_sha256, dhash, created_at, updated_at)
+ VALUES ('pic_01J0000000000000000000000A', 'AbCd1234', $1, 'Summer lake', 'med_pic_test', 'image/jpeg', 'jpg', 640, 480, 1234, 'a', 'b', '0123456789abcdef', $2, $2)`,
+ [owner, stamp]);
+ await t.ctx.s.db.exec(
+ `INSERT INTO video_uploads (id, owner, title, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, status, state, created_at, updated_at)
+ VALUES ('vup_01J0000000000000000000000B', $1, 'Lake walk', 'private', 77, 'video/mp4', 5678, 100, 1, 'ready', 'active', $2, $2)`,
+ [owner, stamp]);
+ await t.ctx.s.db.exec(
+ `INSERT INTO video_uploads (id, owner, title, visibility, content_type, size_bytes, part_size, parts_expected, status, state, created_at, updated_at)
+ VALUES ('vup_01J0000000000000000000000C', $1, 'Still uploading', 'private', 'video/mp4', 9, 9, 1, 'uploading', 'active', $2, $2)`,
+ [owner, stamp]);
+
+ await check('root includes own Pics and ready Video records with source links', async () => {
+ const page = await t.get('/files', { as: kim });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Summer lake') && page.text.includes('Open in Pics'));
+ assert.ok(page.text.includes('Lake walk') && page.text.includes('Open in Video'));
+ assert.ok(!page.text.includes('Still uploading'));
+ });
+ await check('search includes matching source records and excludes other owners', async () => {
+ const page = await t.get('/files?q=lake', { as: kim });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Summer lake') && page.text.includes('Lake walk'));
+ assert.ok(!page.text.includes('Still uploading'));
+ });
+ await done(t);
+})().catch((e) => { console.error(e); process.exitCode = 1; });