openvibers/openvibe.mediahub Commits
Add Download share defaults and audience checks (#400)
Changes
nnew file mode 100644@@@ -0,0 +1,29 @@++-- phase: expand++-- Existing drive files stay private. New uploads choose an audience when the upload starts.++-- Old expiring links keep working until their owner changes the file's sharing. New files and changed files++-- use the share row on every legacy link path as well.++ALTER TABLE mh_files ADD COLUMN IF NOT EXISTS share_v1 boolean NOT NULL DEFAULT false;++ALTER TABLE mh_files ALTER COLUMN share_v1 SET DEFAULT true;++ALTER TABLE mh_uploads ADD COLUMN IF NOT EXISTS audience text NOT NULL DEFAULT 'private';++DO $$ BEGIN++ ALTER TABLE mh_uploads ADD CONSTRAINT mh_uploads_audience_allowed++ CHECK (audience IN ('public', 'link', 'people', 'private'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;++++CREATE TABLE IF NOT EXISTS mh_share_defaults (++ owner text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL,++ audience text NOT NULL,++ updated_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (owner, kind),++ CHECK (kind = 'download'),++ CHECK (audience IN ('public', 'link', 'people', 'private'))++);++++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)++SELECT 'mediahub', 'download', f.id,++ CASE WHEN f.owner LIKE 'user:usr\_%' THEN jsonb_build_object('type', 'user', 'id', substr(f.owner, 6))++ ELSE jsonb_build_object('type', 'service', 'id', 'mediahub') END,++ 'private'++FROM mh_files f WHERE f.deleted_at IS NULL++ON CONFLICT (service, kind, resource_id) DO NOTHING;@@@ -69,7 +69,7 @@});}/** Upload one file into a folder (or the root); onProgress(sent, total). Resolves with the stored file. */-- function sendFile(file, folderId, name, onProgress) {++ function sendFile(file, folderId, name, audience, onProgress) {var sent = 0;var uploadId = null;onProgress(0, file.size);@@@ -81,6 +81,7 @@size: file.size,content_type: file.type || 'application/octet-stream',folder_id: folderId || null,++ audience: audience || 'public',}),}).then(function (started) {var up = started.upload;@@@ -161,9 +162,11 @@return li;}function enqueue(files, folderId, name) {++ var audienceField = document.querySelector('form[data-upload] select[name=audience]');++ var audience = audienceField ? audienceField.value : 'public';for (var i = 0; i < files.length; i++) {if (!files[i] || (files[i].size === 0 && !files[i].type)) continue; // a dropped folder arrives as an empty, typeless entry-- queue.push({ file: files[i], folderId: folderId, name: files.length === 1 ? name : '', row: addRow(files[i]) });++ queue.push({ file: files[i], folderId: folderId, name: files.length === 1 ? name : '', audience: audience, row: addRow(files[i]) });}if (tray) trayHead();next();@@@ -176,7 +179,7 @@trayHead();var state = job.row.querySelector('.dr-tray-state');var fill = job.row.querySelector('.dr-tray-bar i');-- sendFile(job.file, job.folderId, job.name, function (sent, total) {++ sendFile(job.file, job.folderId, job.name, job.audience, function (sent, total) {var pct = total ? Math.round((sent / total) * 100) : 100;fill.style.width = pct + '%';state.textContent = pct + '% of ' + bytes(total);@@@ -28,6 +28,7 @@ const TABLES = [{ table: 'mh_reports', subject: 'reporter', value: AS_REQUESTER, file: 'reports.json' },{ table: 'mh_shares', subject: 'owner', value: AS_REQUESTER, file: 'shares.json' },{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },++ { table: 'mh_share_defaults', subject: 'owner', value: AS_REQUESTER, file: 'share-defaults.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' },@@@ -66,7 +67,7 @@ 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 } }] : [];++ return owned.length || member.length || asked.length ? [{ name: 'uniform-sharing.json', content: { owned, member_of: member, requests: asked } }] : [];}async function shareErase(t, subjects) {for (const subject of subjects) {@@@ -69,15 +69,15 @@ async function createApp(opts = {}) {const media = createMediaClient({ config, fetchImpl, log });const identity = createIdentity({ config, fetchImpl, log });const drive = createService({ s, media, identity, config, log });-- const uploads = createUploads({ s, media, config, service: drive, log });// videoUploads is made below; the account consumer only calls it when an event arrives.const account = createAccount({ db: s.db, config, media, log, onMediaVodReady: (vod) => videoUploads.importRecording(vod) });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 { createSharing, describeRef } = require('./sharing');-- // Sharing (openvibe-sdk/share): the row next to each picture, album and video upload, and the one access check.++ // Sharing (openvibe-sdk/share): the row next to each picture, album, video and drive upload.const sharing = opts.sharing || createSharing({ db: s.db, config, outbox: { emit: (envelope) => eventsOutbox.emit(envelope) }, now: s.now, fetchImpl, log });++ const uploads = createUploads({ s, media, config, service: drive, sharing, 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.@@@ -257,6 +257,8 @@ async function createApp(opts = {}) {{const shareApi = express.Router();shareApi.use(sso.middleware());++ // Express initializes req.body to {} on this host; parse JSON before the SDK reads it.++ shareApi.use(express.json({ limit: '9mb' }));shareApi.use(sharing.shares.router({service: 'mediahub',authenticate: (req) => sharing.shareViewer(req.viewer),@@@ -207,10 +207,10 @@ const usageFor = (s, owner) => s.db.many(`SELECT * FROM ${USAGE} WHERE owner = $async function insertUpload(s, row) {await s.db.query(-- `INSERT INTO ${UPLOADS} (id, owner, object_id, upload_id, name, size, content_type, folder_id, part_size, parts_expected, created_at, expires_at, drop_uploader)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,++ `INSERT INTO ${UPLOADS} (id, owner, object_id, upload_id, name, size, content_type, folder_id, part_size, parts_expected, created_at, expires_at, drop_uploader, audience)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,[row.id, row.owner, row.object_id, row.upload_id, row.name, row.size, row.content_type, row.folder_id,-- row.part_size, row.parts_expected, row.created_at, row.expires_at, row.drop_uploader || null]);++ row.part_size, row.parts_expected, row.created_at, row.expires_at, row.drop_uploader || null, row.audience || 'private']);return getUpload(s, row.id);}@@@ -36,11 +36,14 @@ function readSlice(file, start, length) {});}--function createUploads({ s, media, config, service, log = console }) {++function createUploads({ s, media, config, service, sharing, log = console }) {const nowIso = () => new Date(s.now()).toISOString();/** Media's object is `uploading`; the session is Media's, the row is ours. */-- async function startSession({ owner, name, size, contentType, folderId = null, perDay }) {++ async function startSession({ owner, name, size, contentType, folderId = null, perDay, audience }) {++ if (audience !== undefined && !['public', 'link', 'people', 'private'].includes(audience)) {++ return { ok: false, code: 'upload.audience', detail: 'Choose Everyone, Anyone with the link, Specific people, or Only me.' };++ }const room = await service.uploadAllowed(owner, size, perDay ? { perDay } : {});if (!room.ok) return room;if (folderId && !await service.ownFolder(owner, folderId)) {@@@ -65,6 +68,7 @@ function createUploads({ s, media, config, service, log = console }) {name, size, content_type: contentType || 'application/octet-stream', folder_id: folderId || null,part_size: session.part_size, parts_expected: session.parts_expected,created_at: nowIso(), expires_at: new Date(s.now() + config.uploadTtlSeconds * 1000).toISOString(),++ audience: audience || (sharing ? await sharing.defaultAudience(owner, 'download') : 'private'),});return { ok: true, upload: publicUpload(row, []) };}@@@ -73,7 +77,7 @@ function createUploads({ s, media, config, service, log = console }) {const publicUpload = (row, missing) => ({id: row.id, name: row.name, size: row.size, content_type: row.content_type,folder_id: row.folder_id, part_size: row.part_size, parts_expected: row.parts_expected,-- expires_at: row.expires_at, missing,++ expires_at: row.expires_at, missing, audience: row.audience,status: row.file_id ? 'complete' : 'open',});@@@ -134,6 +138,7 @@ function createUploads({ s, media, config, service, log = console }) {size: Number(object.size_bytes) || row.size, contentType: object.mime_type || row.content_type,sha256: object.content_hash || null, folderId: row.folder_id,});++ if (sharing) await sharing.register(owner, 'download', file.id, row.audience);await store.setUploadFile(s, row.id, file.id);await store.dropUpload(s, row.id);return { ok: true, file };@@@ -151,8 +156,8 @@ function createUploads({ s, media, config, service, log = console }) {* The whole file through the session's parts, read from a spooled file. This is the no-JavaScript path: the* browser posted one request, we wrote it to disk, and Media gets it in parts exactly as it would from a script.*/-- async function uploadSpooled({ owner, file, name, size, contentType, folderId = null }) {-- const started = await startSession({ owner, name, size, contentType, folderId });++ async function uploadSpooled({ owner, file, name, size, contentType, folderId = null, audience }) {++ const started = await startSession({ owner, name, size, contentType, folderId, audience });if (!started.ok) return started;const row = await store.getUpload(s, started.upload.id);try {@@@ -7,7 +7,9 @@** GET /ping anyone liveness* GET /files?folder_id=&limit=&before=&q= person your drive (one folder, the root, or every folder by name)-- * POST /files person start an upload: { name, size, content_type, folder_id }++ * POST /files person start an upload: { name, size, content_type, folder_id, audience? }++ * GET /share-defaults/download person audience for new uploads++ * PUT /share-defaults/download person { audience, apply? }* GET /files/:id person one of your files* PATCH /files/:id person { name?, folder_id? (null = the top) }* DELETE /files/:id person put it in the bin (or ?forever=1: delete a binned file now)@@@ -58,7 +60,7 @@ function readBody(req, cap) {}function createApi(ctx) {-- const { config, s, principal, limits, drive, uploads, syncKeys, drops, media } = ctx;++ const { config, s, principal, limits, drive, uploads, syncKeys, drops, media, sharing } = ctx;const r = asyncRouter();const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });@@@ -201,6 +203,24 @@ function createApi(ctx) {// ── Liveness ────────────────────────────────────────────r.get('/ping', limits.reads('media-hub.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));++ r.get('/share-defaults/:kind', person, async (req, res) => {++ const kind = String(req.params.kind || '');++ if (kind !== 'download') return problem(req, res, 404, 'share.kind', 'No such share kind.');++ return res.json({ kind, audience: await sharing.defaultAudience(personOf(req), kind) });++ });++++ r.put('/share-defaults/:kind', person, async (req, res) => {++ const kind = String(req.params.kind || '');++ const audience = String((req.body || {}).audience || '');++ if (kind !== 'download' || !['public', 'link', 'people', 'private'].includes(audience)) {++ return problem(req, res, 422, 'share.default_invalid', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');++ }++ const owner = personOf(req);++ await sharing.setDefaultAudience(owner, kind, audience);++ const changed = req.body && req.body.apply === true ? await sharing.applyDefault(owner, kind, audience) : 0;++ return res.json({ kind, audience, changed });++ });++// ── Files ───────────────────────────────────────────────r.get('/files', person, limits.reads('media-hub.file.read'), async (req, res) => {const owner = personOf(req);@@@ -238,8 +258,9 @@ function createApi(ctx) {const contentType = rules.normalizeType(b.content_type) || 'application/octet-stream';const folderId = b.folder_id ? String(b.folder_id) : null;if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');++ if (req.principal.viaSyncKey && b.audience && b.audience !== 'private') return problem(req, res, 403, 'sync_key.not_allowed', 'A sync key cannot share a backup.');const perDay = req.principal.viaSyncKey ? config.quotas.syncUploadsPerDay : undefined;-- const out = await uploads.startSession({ owner, name, size, contentType, folderId, perDay });++ const out = await uploads.startSession({ owner, name, size, contentType, folderId, perDay, audience: req.principal.viaSyncKey ? 'private' : b.audience });if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);res.set('Location', `/api/v1/uploads/${out.upload.id}`);return res.status(201).json({@@@ -66,6 +66,25 @@ route('get', '/ping', {responses: { 200: json('The service answers', { type: 'object', properties: { ok: { const: true }, service: str() } }), ...errors([429]) },});++// ── Download audience default ─────────────────────────────────────────────────────────────────++route('get', '/share-defaults/{kind}', {++ operationId: 'getShareDefault', summary: 'Audience for new Download uploads', tags: ['shares'], security: PERSON,++ parameters: [path('kind', str({ enum: ['download'] }))],++ responses: { 200: json('The saved default, or Everyone when none is saved', { type: 'object', properties: {++ kind: str(), audience: str({ enum: ['public', 'link', 'people', 'private'] }),++ } }), ...errors([401, 404, 429]) },++});++route('put', '/share-defaults/{kind}', {++ operationId: 'setShareDefault', summary: 'Save a Download audience default and optionally apply it to existing files', tags: ['shares'], security: PERSON,++ parameters: [path('kind', str({ enum: ['download'] }))],++ requestBody: body({ type: 'object', required: ['audience'], properties: {++ audience: str({ enum: ['public', 'link', 'people', 'private'] }), apply: { type: 'boolean' },++ } }),++ responses: { 200: json('The default and number of existing files changed', { type: 'object', properties: {++ kind: str(), audience: str(), changed: integer(),++ } }), ...errors([401, 403, 422, 429]) },++});++// ── Files ──────────────────────────────────────────────────────────────────────────────────────route('get', '/files', {operationId: 'listFiles', summary: 'One folder of your drive (or the root), or a search of every folder', tags: ['files'], security: PERSON,@@@ -80,6 +99,7 @@ route('post', '/files', {description: 'A signed-in session write must come from openvibe.download itself. 201 carries Location: /api/v1/uploads/{id}.',requestBody: body({ type: 'object', required: ['name', 'size'], properties: {name: str(), size: integer({ minimum: 0 }), content_type: str(), folder_id: str(),++ audience: str({ enum: ['public', 'link', 'people', 'private'], description: 'Overrides the saved default for this upload. A sync key can use Only me only.' }),} }),responses: { 201: json('The upload session and what is still missing', { type: 'object', properties: { upload: { type: 'object' }, note: str() } }), ...errors() },});@@@ -80,7 +80,7 @@ function driveScene() {}function createPageRoutes(ctx) {-- const { config, s, drive, uploads, media, syncKeys, drops } = ctx;++ const { config, s, drive, uploads, media, syncKeys, drops, sharing } = ctx;const r = asyncRouter();// The pages' forms are web forms (no JavaScript): urlencoded text, and one multipart file upload whose body the// upload route reads itself. The JSON parser is deliberately absent here.@@@ -310,10 +310,11 @@ ${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is</nav>`;/** The upload: a big "Upload files" target over a plain file form. upload.js takes it over (many files, the tray). */-- const uploadForm = (folder) => html`<form class="dr-upload" method="post" action="/files/upload" enctype="multipart/form-data" data-upload>++ const uploadForm = (folder, audience) => html`<form class="dr-upload" method="post" action="/files/upload" enctype="multipart/form-data" data-upload>${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}<input id="file" class="dr-pick-in" name="file" type="file" required><label class="dr-pick" for="file">${ui.icon('upload')}<span><b>Upload files</b><small>Or drop them anywhere on this page</small></span></label>++<label>Who can open a new file <select name="audience"><option value="public"${audience === 'public' ? raw(' selected') : ''}>Everyone</option><option value="link"${audience === 'link' ? raw(' selected') : ''}>Anyone with the link</option><option value="people"${audience === 'people' ? raw(' selected') : ''}>Specific people</option><option value="private"${audience === 'private' ? raw(' selected') : ''}>Only me</option></select></label><div class="dr-upload-go" data-nojs><input id="name" name="name" type="text" maxlength="${rules.NAME_MAX}" placeholder="Name (optional)" aria-label="Name (optional)"><button class="dr-btn is-primary" type="submit">Upload</button></div><progress id="upload-progress" max="100" value="0" hidden></progress><p id="upload-status" class="dr-fine" role="status"></p>@@@ -356,6 +357,7 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}}const usage = await drive.usage(owner);const binCount = await drive.binCount(owner);++ const shareDefault = await sharing.defaultAudience(owner, 'download');const title = q ? `“${q}”` : folder ? folder.name : 'My files';const empty = !files.length && !folders.length;const keep = (extra) => html`${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}${extra || ''}`;@@@ -370,7 +372,8 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}<details class="dr-new" data-new><summary class="dr-new-btn">${ui.icon('plus')}<span>New</span></summary><div class="dr-new-panel" role="group" aria-label="Add to ${folder ? folder.name : 'My files'}"><p class="dr-new-title">Add to <b>${folder ? folder.name : 'My files'}</b></p>--${uploadForm(folder)}++${uploadForm(folder, shareDefault)}++<form method="post" action="/files/share-default"><label>New files <select name="audience"><option value="public"${shareDefault === 'public' ? raw(' selected') : ''}>Everyone</option><option value="link"${shareDefault === 'link' ? raw(' selected') : ''}>Anyone with the link</option><option value="people"${shareDefault === 'people' ? raw(' selected') : ''}>Specific people</option><option value="private"${shareDefault === 'private' ? raw(' selected') : ''}>Only me</option></select></label><label><input type="checkbox" name="apply" value="1"> Apply to all my existing files</label><button class="ovk-btn ovk-btn--quiet" type="submit">Save default</button></form>${folderForm(folder)}<a class="dr-new-row" href="/sync">${ui.icon('sync')}<span><b>Back up a folder</b><small>Keep a folder on your computer copied here</small></span></a></div></details>@@@ -441,10 +444,37 @@ ${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.fi});});-- r.get('/files/:id', mustSignIn('/files'), async (req, res) => {++ r.post('/files/share-default', mustSignIn('/files'), async (req, res) => {++ if (!sameSite(req, res, '/files')) return undefined;++ const audience = String((req.body || {}).audience || '');++ if (!['public', 'link', 'people', 'private'].includes(audience)) return res.status(422).type('text/plain').send('Choose Everyone, Anyone with the link, Specific people, or Only me.');const owner = requesterOf(req);-- const row = await drive.ownFile(owner, String(req.params.id || ''));++ await sharing.setDefaultAudience(owner, 'download', audience);++ if (req.body && req.body.apply === '1') await sharing.applyDefault(owner, 'download', audience);++ return res.redirect(303, '/files');++ });++++ r.get('/files/:id', async (req, res) => {++ const id = String(req.params.id || '');++ const row = rules.isFileId(id) ? await ctx.store.getFile(s, id) : null;++ const mine = row && signedIn(req) && row.owner === requesterOf(req);++ if (row && (row.deleted_at || row.binned_at)) return notFoundFile(req, res);++ if (row && !mine && !await sharing.canView(req.viewer, 'download', row.id)) {++ const share = await sharing.shareOf('download', row.id);++ if (share && share.audience === 'people') return page(req, res, {++ title: 'A file on OpenVibe.Download', noindex: true,++ body: html`<h1>A file on OpenVibe.Download</h1><p>This is shared with specific people.</p>++<div data-ov-share-gate data-kind="download" data-id="${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>Sign in to ask for access.</p></div>++<link rel="stylesheet" href="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`,++ }, 404);++ return notFoundFile(req, res);++ }if (!row) return notFoundFile(req, res);++ if (!mine) return page(req, res, {++ title: row.name,++ body: html`<h1>${row.name}</h1><p>${fileSize(row)} · ${row.content_type}</p><p><a class="ovk-btn ovk-btn--primary" href="/files/${row.id}/download">Download</a> ${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name }))}</p>`,++ });++ const owner = requesterOf(req);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);@@@ -470,7 +500,7 @@ ${pathBar(path, row.name)}<div class="fv-side"><h1 class="fv-name">${row.name}</h1><p class="fv-sub"><span class="fv-kind" data-kind="${kind}">${ui.icon(ui.KINDS[kind].icon)}${ui.kindWord(row)}</span><span>${fileSize(row)}</span><span>Added ${ui.when(row.created_at)}</span></p>--<div class="fv-actions"><a class="dr-btn is-primary" href="/files/${row.id}/download">${ui.icon('download')}Download</a>${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: pic ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '', cls: 'dr-btn' }))}${raw(askAgent({ text: `About my file “${row.name}” (${ui.kindWord(row)}, ${fileSize(row)}): ` }))}</div>++<div class="fv-actions"><a class="dr-btn is-primary" href="/files/${row.id}/download">${ui.icon('download')}Download</a>${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: pic ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '', cls: 'dr-btn', kind: 'download', id: row.id }))}${raw(askAgent({ text: `About my file “${row.name}” (${ui.kindWord(row)}, ${fileSize(row)}): ` }))}</div>${/^audio\/(mpeg|mp4|ogg|webm|wav|flac)$/.test(row.content_type) ? html`<section class="fv-card"><h2>OpenVibe Radio</h2>${radioTrack? html`<p>This music is published for listening parties.</p><a class="dr-btn" href="/radio/tracks/${radioTrack.id}">Open in Radio</a>`: html`<p>Mark this audio file as music and choose a licence before anyone can listen in a party.</p><a class="dr-btn" href="/radio/publish?file=${row.id}">Publish as music</a>`}</section>` : ''}@@@ -564,6 +594,7 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ulname: rules.fileName(got.fields.name || got.file.filename, got.file.filename),contentType: rules.normalizeType(got.file.contentType) || 'application/octet-stream',folderId: folder ? folder.id : null,++ audience: got.fields.audience,});// A refusal by our own rules is shown as it came; a Media outage is one sentence, not Media's own words.if (!out.ok) return uploadFailed(req, res, String(out.code).startsWith('media.')@@@ -790,13 +821,21 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ulreturn undefined;}-- r.get('/files/:id/preview', mustSignIn('/files'), async (req, res) => {-- const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));++ async function fileForRead(req) {++ const id = String(req.params.id || '');++ const row = rules.isFileId(id) ? await ctx.store.getFile(s, id) : null;++ if (!row || row.deleted_at || row.binned_at) return null;++ if (signedIn(req) && row.owner === requesterOf(req)) return row;++ return await sharing.canView(req.viewer, 'download', row.id) ? row : null;++ }++++ r.get('/files/:id/preview', async (req, res) => {++ const row = await fileForRead(req);return sendPreview(req, res, row);});-- r.get('/files/:id/download', mustSignIn('/files'), async (req, res) => {-- const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));++ r.get('/files/:id/download', async (req, res) => {++ const row = await fileForRead(req);if (!row) return notFoundFile(req, res);return sendFile(req, res, row);});@@@ -836,6 +875,13 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul}, 403);return null;}++ if (share.file_id) {++ const file = await fileById(share);++ if (file && file.share_v1 && !await sharing.canView(req.viewer, 'download', file.id)) {++ notFoundFile(req, res);++ return null;++ }++ }return share;}@@@ -877,7 +923,8 @@ ${mine ? '' : reportForm(share)}<p class="small muted">${REPORT_NOTE}</p>`,});}-- const files = await drive.listFiles(share.owner, { folderId: share.folder_id, limit: 200 });++ const files = (await Promise.all((await drive.listFiles(share.owner, { folderId: share.folder_id, limit: 200 }))++ .map(async (f) => !f.share_v1 || await sharing.canView(req.viewer, 'download', f.id) ? f : null))).filter(Boolean);const folder = await drive.ownFolder(share.owner, share.folder_id);return page(req, res, {title: `${folder ? folder.name : 'A folder'} · shared by ${share.owner_username || 'an OpenVibe member'}`,@@@ -933,6 +980,7 @@ ${mine ? '' : reportForm(share)}`,body: html`<h1>No such file in this share</h1><p class="muted"><a href="/s/${share.slug}">The share page</a>.</p>`,}, 404);}++ if (file.share_v1 && !await sharing.canView(req.viewer, 'download', file.id)) return notFoundFile(req, res);await ctx.store.countDownload(s, share.slug);return sendFile(req, res, file);});@@@ -16,14 +16,14 @@* 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 { createShares, ShareError } = 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 TO_SDK = { public: 'public', link: 'link', unlisted: 'link', people: 'people', 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) });@@@ -104,15 +104,54 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT} 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]);++ } else if (ref.kind === 'download') {++ await tx.exec('UPDATE mh_files SET share_v1 = true WHERE id = $1', [ref.id]);++ if (audience === 'people' || audience === 'private') {++ // An expiring legacy file or folder link must not bypass a narrower Share choice.++ await tx.exec(`UPDATE mh_shares SET revoked_at = coalesce(revoked_at, now()::text)++ WHERE (file_id = $1 OR folder_id = (SELECT folder_id FROM mh_files WHERE id = $1)) AND revoked_at IS NULL`, [ref.id]);++ await tx.exec('UPDATE mh_agent_grants SET revoked_at = coalesce(revoked_at, now()::text) WHERE file_id = $1 AND revoked_at IS NULL', [ref.id]);++ await tx.exec('UPDATE mh_radio_tracks SET revoked_at = coalesce(revoked_at, now()) WHERE file_id = $1 AND revoked_at IS NULL', [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 {} });},});++ // Media binds drive bytes to their original owner. Moving only this database row would let that person's++ // later account deletion remove a file someone else now owns, so reject a transfer until Media can move it too.++ const rawTransfer = shares.transfer.bind(shares);++ shares.transfer = (viewer, ref, input) => {++ if (ref.kind === 'download') throw new ShareError(409, 'share.transfer_unavailable', 'This file cannot be transferred yet');++ return rawTransfer(viewer, ref, input);++ };const sv = (viewer) => shareViewer(viewer);const at = (kind, id) => refOf(kind, id);++ async function defaultAudience(owner, kind) {++ if (kind !== 'download') throw new Error('Unknown share kind');++ const row = await db.maybe('SELECT audience FROM mh_share_defaults WHERE owner = $1 AND kind = $2', [owner, kind]);++ return row ? row.audience : 'public';++ }++ async function setDefaultAudience(owner, kind, audience) {++ if (kind !== 'download' || !AUDIENCES.includes(audience)) throw new Error('Invalid share default');++ await db.exec(`INSERT INTO mh_share_defaults (owner, kind, audience) VALUES ($1, $2, $3)++ ON CONFLICT (owner, kind) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [owner, kind, audience]);++ }++ async function applyDefault(owner, kind, audience) {++ if (kind !== 'download' || !AUDIENCES.includes(audience)) throw new Error('Invalid share default');++ const rows = await db.many('SELECT id FROM mh_files WHERE owner = $1 AND deleted_at IS NULL AND binned_at IS NULL', [owner]);++ const viewer = { subject: subjectOf(owner) };++ let changed = 0;++ for (const row of rows) {++ const current = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, kind, row.id]);++ if (!current) { await shares.register(at(kind, row.id), { owner_subject: subjectOf(owner), audience }); changed++; continue; }++ if (current && current.audience !== audience) { await shares.setAudience(viewer, at(kind, row.id), audience); changed++; }++ }++ return changed;++ }++return {shares,SERVICE, KINDS, AUDIENCES, TO_SDK, FROM_SDK, refOf, shareViewer, subjectOf,@@@ -121,6 +160,7 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalTowner_subject: subjectOf(requester) || { type: 'service', id: SERVICE },audience: TO_SDK[audience] || 'public',}),++ defaultAudience, setDefaultAudience, applyDefault,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)),@@@ -170,6 +210,11 @@ function describeRef(db, config) {if (!row) return {};return { title: row.title || 'A video', link: `${base}${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };}++ if (ref.kind === 'download') {++ const row = await db.maybe('SELECT id, name FROM mh_files WHERE id = $1 AND deleted_at IS NULL AND binned_at IS NULL', [ref.id]);++ if (!row) return {};++ return { title: row.name, link: `${base}/files/${row.id}`, roles: ['viewer'], audiences: AUDIENCES, transferable: false };++ }return {};};}@@@ -75,6 +75,7 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';});await check('the export carries the person\'s files, folders, shares, reports and upload counts', async () => {++ await t.ctx.sharing.setDefaultAudience(`user:${kim.subject}`, 'download', 'private');const r = await deliver(envelope('network.account.export_requested', { export_id: EXPORT, subject: kim.subject }));assert.strictEqual(r.status, 200, r.text.slice(0, 300));assert.strictEqual(r.json().outcome, 'exported');@@@ -85,7 +86,9 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';assert.ok(files, 'a files.json part');assert.strictEqual(files.content.length, 1);assert.strictEqual(files.content[0].id, file.id);-- assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['files.json', 'radio_tracks.json', 'reports.json', 'shares.json', 'usage.json', 'video_likes.json'], 'and the rest of the person\'s own data');++ assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['files.json', 'radio_tracks.json', 'reports.json', 'share-defaults.json', 'shares.json', 'uniform-sharing.json', 'usage.json', 'video_likes.json'], 'and the rest of the person\'s own data');++ assert.strictEqual(part.files.find((f) => f.name === 'share-defaults.json').content[0].audience, 'private');++ assert.ok(part.files.find((f) => f.name === 'uniform-sharing.json').content.owned.some((s) => s.kind === 'download' && s.resource_id === file.id));assert.deepStrictEqual(part.files.find((f) => f.name === 'radio_tracks.json').content.map((track) => track.id), ['radio_kim'], 'a person can export their radio publications');assert.deepStrictEqual(part.files.find((f) => f.name === 'video_likes.json').content.map((like) => like.item_id), [101], 'a person can export their likes');assert.ok(part.files.find((f) => f.name === 'shares.json').content.some((s) => s.slug === slug));@@@ -112,6 +115,7 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';assert.strictEqual(confirmation.subject, kim.subject);assert.ok(confirmation.erased.mh_files >= 1, JSON.stringify(confirmation));assert.ok(confirmation.erased.mh_shares >= 1, JSON.stringify(confirmation));++ assert.strictEqual(confirmation.erased.mh_share_defaults, 1, 'the saved share default is erased');assert.strictEqual(confirmation.erased.mh_radio_tracks, 1, 'radio publications are erased with the account');assert.strictEqual(confirmation.erased.video_likes, 1, 'likes are erased with the account');assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM mh_radio_tracks WHERE owner = $1', [`user:${sam.subject}`])), 1, 'another person\'s radio publication remains');nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, bytes, upload, share } = require('./helpers/drive');++++(async () => {++ const t = await boot();++ const owner = t.network.addUser('owner');++ const reader = t.network.addUser('reader');++ const outsider = t.network.addUser('outsider');++ const putShare = (id, body) => t.get(`/api/v1/download/${id}/share`, {++ as: owner, method: 'PUT', json: body, headers: { ...SAME, 'x-requested-with': 'ov-share' },++ });++ try {++ await check('new downloads have a share row and the upload audience controls page and bytes', async () => {++ const data = bytes(350, 'open');++ const made = await upload(t, owner, data, { name: 'open.txt' });++ assert.strictEqual(made.status, 201, made.text);++ const id = made.json().file.id;++ const row = await t.ctx.sharing.shareOf('download', id);++ assert.strictEqual(row.audience, 'public');++ assert.strictEqual((await t.get(`/files/${id}`)).status, 200);++ assert.ok((await t.get(`/files/${id}/download`)).buffer.equals(data));++ const ownPage = await t.get(`/files/${id}`, { as: owner });++ assert.ok(ownPage.text.includes(`data-kind="download" data-id="${id}"`));++ const transfer = await t.get(`/api/v1/download/${id}/share/transfer`, {++ as: owner, json: { to: { type: 'user', id: reader.subject }, keep_access: false }, headers: SAME,++ });++ assert.strictEqual(transfer.status, 409, transfer.text);++++ const privateFile = (await upload(t, owner, bytes(360, 'private'), { name: 'private.txt', audience: 'private' })).json().file.id;++ assert.strictEqual((await t.get(`/files/${privateFile}`)).status, 404);++ assert.strictEqual((await t.get(`/files/${privateFile}/download`, { as: outsider })).status, 404);++ assert.strictEqual((await t.get(`/files/${privateFile}`, { as: owner })).status, 200);++ const linkFile = (await upload(t, owner, bytes(370, 'link'), { audience: 'link' })).json().file.id;++ assert.strictEqual((await t.get(`/files/${linkFile}`)).status, 200);++ });++++ await check('Specific people gate both the page and bytes, and narrowing revokes old links', async () => {++ const data = bytes(420, 'named');++ const id = (await upload(t, owner, data, { name: 'named.txt' })).json().file.id;++ const legacy = (await share(t, owner, { file_id: id, public: true })).json().share.slug;++ const narrowed = await putShare(id, { audience: 'people', members: [{ subject: { type: 'user', id: reader.subject }, role: 'viewer' }] });++ assert.strictEqual(narrowed.status, 200, narrowed.text);++ assert.strictEqual((await t.get(`/files/${id}`)).status, 404);++ const denied = await t.get(`/files/${id}`, { as: outsider });++ assert.strictEqual(denied.status, 404);++ assert.ok(denied.text.includes('data-ov-share-gate') && !denied.text.includes('named.txt'));++ assert.strictEqual((await t.get(`/files/${id}/download`, { as: outsider })).status, 404);++ assert.strictEqual((await t.get(`/files/${id}`, { as: reader })).status, 200);++ assert.ok((await t.get(`/files/${id}/download`, { as: reader })).buffer.equals(data));++ assert.strictEqual((await t.get(`/s/${legacy}`)).status, 404);++ });++++ await check('a shared folder hides and does not count a private new file', async () => {++ const folder = (await t.get('/api/v1/folders', { as: owner, json: { name: 'shared' }, headers: SAME })).json().folder;++ const id = (await upload(t, owner, bytes(430, 'folder secret'), { name: 'folder-secret.txt', folder: folder.id, audience: 'private' })).json().file.id;++ const slug = (await share(t, owner, { folder_id: folder.id, public: true })).json().share.slug;++ const page = await t.get(`/s/${slug}`);++ assert.strictEqual(page.status, 200, page.text);++ assert.ok(!page.text.includes('folder-secret.txt') && page.text.includes('0 files'));++ assert.strictEqual((await t.get(`/s/${slug}/f/${id}/download`)).status, 404);++ });++++ await check('default plus apply to all changes only the owner’s files; upload may override it', async () => {++ const a = (await upload(t, owner, bytes(440, 'a'))).json().file.id;++ const b = (await upload(t, outsider, bytes(450, 'b'))).json().file.id;++ const saved = await t.get('/api/v1/share-defaults/download', { as: owner, method: 'PUT', json: { audience: 'private', apply: true }, headers: SAME });++ assert.strictEqual(saved.status, 200, saved.text);++ assert.ok(saved.json().changed >= 1);++ assert.strictEqual((await t.get(`/files/${a}`)).status, 404);++ assert.strictEqual((await t.get(`/files/${b}`)).status, 200);++ const next = (await upload(t, owner, bytes(460, 'next'))).json().file.id;++ assert.strictEqual((await t.ctx.sharing.shareOf('download', next)).audience, 'private');++ const override = (await upload(t, owner, bytes(470, 'override'), { audience: 'public' })).json().file.id;++ assert.strictEqual((await t.get(`/files/${override}`)).status, 200);++ const namedDefault = await t.get('/api/v1/share-defaults/download', { as: owner, method: 'PUT', json: { audience: 'people' }, headers: SAME });++ assert.strictEqual(namedDefault.status, 200, namedDefault.text);++ const named = (await upload(t, owner, bytes(480, 'named-default'))).json().file.id;++ assert.strictEqual((await t.ctx.sharing.shareOf('download', named)).audience, 'people');++ assert.strictEqual((await t.get(`/files/${named}`)).status, 404);++ assert.strictEqual((await t.get(`/files/${named}`, { as: owner })).status, 200);++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -96,12 +96,12 @@ const { SAME, bytes, sha256, upload, share } = require('./helpers/drive');assert.strictEqual(part.headers.get('content-disposition'), `attachment; filename="slice.bin"; filename*=UTF-8''slice.bin`);});-- await check('nothing is served to anybody who is not signed in', async () => {-- const file = await put('guarded.bin', bytes(30, 'guarded'), 'application/octet-stream');++ await check('a file chosen as Only me sends no bytes to a guest; an ordinary expiring link still asks for sign-in', async () => {++ const file = (await upload(t, kim, bytes(30, 'guarded'), { name: 'guarded.bin', audience: 'private' })).json().file;const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;-- for (const p of [`/files/${file.id}/download`, `/s/${slug}/download`]) {++ for (const [p, status] of [[`/files/${file.id}/download`, 404], [`/s/${slug}/download`, 401]]) {const r = await t.get(p);-- assert.strictEqual(r.status, 401, p);++ assert.strictEqual(r.status, status, p);assert.ok(!r.buffer.includes(Buffer.from('guarded')), `${p}: no bytes to a guest`);}});@@@ -14,14 +14,15 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');const kim = t.network.addUser('kim');const sam = t.network.addUser('sam');-- await check('a guest gets a sign-in page for every file page, never a file', async () => {-- for (const p of ['/files', '/files/bin', '/files/fil_01JABCDEFGHJKMNPQRSTVWXYZ0', '/staff', '/s/abcdefghijklmnopqrstuvwx']) {++ await check('private drive pages ask a guest to sign in; a missing file reveals nothing', async () => {++ for (const p of ['/files', '/files/bin', '/staff', '/s/abcdefghijklmnopqrstuvwx']) {const r = await t.get(p);assert.strictEqual(r.status, 401, p);assert.ok(r.text.includes('Sign in'), `${p}: a sign-in page`);assert.ok(r.text.includes('/auth/login?next='), `${p}: with a way to sign in`);assert.ok(!r.text.includes('<progress'), `${p}: and nothing of the drive`);}++ assert.strictEqual((await t.get('/files/fil_01JABCDEFGHJKMNPQRSTVWXYZ0')).status, 404);// The home page and the safety rules are public: they are how somebody decides to sign up at all.assert.strictEqual((await t.get('/')).status, 200);assert.strictEqual((await t.get('/safety')).status, 200);@@@ -47,11 +47,12 @@ const { bytes, upload, share } = require('./helpers/drive');});await check('no preview for other people\'s files, for files that are not images, or for SVG', async () => {-- assert.strictEqual((await t.get(`/files/${photo.id}/preview?w=96`, { as: sam })).status, 404);++ const secret = (await upload(t, kim, bytes(257, 'secret png'), { name: 'secret.png', contentType: 'image/png', audience: 'private' })).json().file;++ assert.strictEqual((await t.get(`/files/${secret.id}/preview?w=96`, { as: sam })).status, 404);assert.strictEqual((await t.get(`/files/${notes.id}/preview?w=96`, { as: kim })).status, 404);assert.strictEqual((await t.get(`/files/${vector.id}/preview?w=96`, { as: kim })).status, 404);-- const anon = await t.get(`/files/${photo.id}/preview?w=96`);-- assert.ok([302, 303, 401].includes(anon.status), `signed out: ${anon.status}`);++ assert.strictEqual((await t.get(`/files/${secret.id}/preview?w=96`)).status, 404);++ assert.strictEqual((await t.get(`/files/${photo.id}/preview?w=96`, { as: sam })).status, 200);});await check('a single-file share shows its picture to whoever may open it; it is not a download', async () => {@@@ -117,10 +117,10 @@ const MB = 1024 * 1024;assert.ok(stored > 6 * MB);});-- await check('an upload is private to its owner until it is shared', async () => {++ await check('an upload chosen as Only me is private to its owner', async () => {// Somebody with their own numbers: kim's day is full by now, which is a different rule.const nia = t.network.addUser('nia');-- const up = await upload(t, nia, bytes(200, 'private'), { name: 'mine.bin' });++ const up = await upload(t, nia, bytes(200, 'private'), { name: 'mine.bin', audience: 'private' });assert.strictEqual(up.status, 201, up.text);const id = up.json().file.id;assert.strictEqual((await t.get(`/api/v1/files/${id}`, { as: sam })).status, 404);@@@ -21,10 +21,10 @@ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');* Upload through the API the way the page's script does: POST /files, one PUT per part, then complete.* Answers the final response (t.get's shape), so a caller can assert on it directly.*/--async function upload(t, as, data, { name = 'file.bin', contentType = 'application/octet-stream', folder = null, onPart = null } = {}) {++async function upload(t, as, data, { name = 'file.bin', contentType = 'application/octet-stream', folder = null, audience, onPart = null } = {}) {const started = await t.get('/api/v1/files', {as, headers: SAME,-- json: { name, size: data.length, content_type: contentType, folder_id: folder },++ json: { name, size: data.length, content_type: contentType, folder_id: folder, audience },});if (started.status !== 201 || !started.json().upload) return started;const up = started.json().upload;@@@ -57,7 +57,9 @@ const MB = 1024 * 1024;assert.strictEqual(part.status, 200, part.text);const done_ = await t.get(`/api/v1/uploads/${up.id}/complete`, { bearer: key, json: {} });assert.strictEqual(done_.status, 201, done_.text);++ assert.strictEqual((await t.get(`/files/${done_.json().file.id}`)).status, 404, 'a backup is private even when the interactive upload default is Everyone');}++ assert.strictEqual((await t.get('/api/v1/files', { bearer: key, json: { name: 'shared.txt', size: 10, content_type: 'text/plain', audience: 'public' } })).status, 403);const fourth = await t.get('/api/v1/files', { bearer: key, json: { name: 'note-4.txt', size: 10, content_type: 'text/plain' } });assert.strictEqual(fourth.status, 429, 'past the sync cap');assert.strictEqual(fourth.json().code, 'file.daily_limit');
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+-- phase: expand
+-- Existing drive files stay private. New uploads choose an audience when the upload starts.
+-- Old expiring links keep working until their owner changes the file's sharing. New files and changed files
+-- use the share row on every legacy link path as well.
+ALTER TABLE mh_files ADD COLUMN IF NOT EXISTS share_v1 boolean NOT NULL DEFAULT false;
+ALTER TABLE mh_files ALTER COLUMN share_v1 SET DEFAULT true;
+ALTER TABLE mh_uploads ADD COLUMN IF NOT EXISTS audience text NOT NULL DEFAULT 'private';
+DO $$ BEGIN
+ ALTER TABLE mh_uploads ADD CONSTRAINT mh_uploads_audience_allowed
+ CHECK (audience IN ('public', 'link', 'people', 'private'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
+CREATE TABLE IF NOT EXISTS mh_share_defaults (
+ owner text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL,
+ audience text NOT NULL,
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (owner, kind),
+ CHECK (kind = 'download'),
+ CHECK (audience IN ('public', 'link', 'people', 'private'))
+);
+
+INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
+SELECT 'mediahub', 'download', f.id,
+ CASE WHEN f.owner LIKE 'user:usr\_%' THEN jsonb_build_object('type', 'user', 'id', substr(f.owner, 6))
+ ELSE jsonb_build_object('type', 'service', 'id', 'mediahub') END,
+ 'private'
+FROM mh_files f WHERE f.deleted_at IS NULL
+ON CONFLICT (service, kind, resource_id) DO NOTHING;
@@ -69,7 +69,7 @@
@@ -69,7 +69,7 @@
});
});
}
}
/** Upload one file into a folder (or the root); onProgress(sent, total). Resolves with the stored file. */
/** Upload one file into a folder (or the root); onProgress(sent, total). Resolves with the stored file. */
- function sendFile(file, folderId, name, onProgress) {
+ function sendFile(file, folderId, name, audience, onProgress) {
var sent = 0;
var sent = 0;
var uploadId = null;
var uploadId = null;
onProgress(0, file.size);
onProgress(0, file.size);
@@ -81,6 +81,7 @@
@@ -81,6 +81,7 @@
size: file.size,
size: file.size,
content_type: file.type || 'application/octet-stream',
content_type: file.type || 'application/octet-stream',
folder_id: folderId || null,
folder_id: folderId || null,
+ audience: audience || 'public',
}),
}),
}).then(function (started) {
}).then(function (started) {
var up = started.upload;
var up = started.upload;
@@ -161,9 +162,11 @@
@@ -161,9 +162,11 @@
return li;
return li;
}
}
function enqueue(files, folderId, name) {
function enqueue(files, folderId, name) {
+ var audienceField = document.querySelector('form[data-upload] select[name=audience]');
+ var audience = audienceField ? audienceField.value : 'public';
for (var i = 0; i < files.length; i++) {
for (var i = 0; i < files.length; i++) {
if (!files[i] || (files[i].size === 0 && !files[i].type)) continue; // a dropped folder arrives as an empty, typeless entry
if (!files[i] || (files[i].size === 0 && !files[i].type)) continue; // a dropped folder arrives as an empty, typeless entry
- queue.push({ file: files[i], folderId: folderId, name: files.length === 1 ? name : '', row: addRow(files[i]) });
+ queue.push({ file: files[i], folderId: folderId, name: files.length === 1 ? name : '', audience: audience, row: addRow(files[i]) });
}
}
if (tray) trayHead();
if (tray) trayHead();
next();
next();
@@ -176,7 +179,7 @@
@@ -176,7 +179,7 @@
trayHead();
trayHead();
var state = job.row.querySelector('.dr-tray-state');
var state = job.row.querySelector('.dr-tray-state');
var fill = job.row.querySelector('.dr-tray-bar i');
var fill = job.row.querySelector('.dr-tray-bar i');
- sendFile(job.file, job.folderId, job.name, function (sent, total) {
+ sendFile(job.file, job.folderId, job.name, job.audience, function (sent, total) {
var pct = total ? Math.round((sent / total) * 100) : 100;
var pct = total ? Math.round((sent / total) * 100) : 100;
fill.style.width = pct + '%';
fill.style.width = pct + '%';
state.textContent = pct + '% of ' + bytes(total);
state.textContent = pct + '% of ' + bytes(total);
@@ -28,6 +28,7 @@ const TABLES = [
@@ -28,6 +28,7 @@ const TABLES = [
{ table: 'mh_reports', subject: 'reporter', value: AS_REQUESTER, file: 'reports.json' },
{ table: 'mh_reports', subject: 'reporter', value: AS_REQUESTER, file: 'reports.json' },
{ table: 'mh_shares', subject: 'owner', value: AS_REQUESTER, file: 'shares.json' },
{ table: 'mh_shares', subject: 'owner', value: AS_REQUESTER, file: 'shares.json' },
{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },
{ table: 'mh_usage', subject: 'owner', value: AS_REQUESTER, file: 'usage.json' },
+ { table: 'mh_share_defaults', subject: 'owner', value: AS_REQUESTER, file: 'share-defaults.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_agent_grants', subject: 'owner', value: AS_REQUESTER, file: 'agent_grants.json' },
@@ -66,7 +67,7 @@ async function shareExport(db, subject) {
@@ -66,7 +67,7 @@ async function shareExport(db, subject) {
const owned = await db.many("SELECT * FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['mediahub', 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 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]);
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 } }] : [];
+ return owned.length || member.length || asked.length ? [{ name: 'uniform-sharing.json', content: { owned, member_of: member, requests: asked } }] : [];
}
}
async function shareErase(t, subjects) {
async function shareErase(t, subjects) {
for (const subject of subjects) {
for (const subject of subjects) {
@@ -69,15 +69,15 @@ async function createApp(opts = {}) {
@@ -69,15 +69,15 @@ async function createApp(opts = {}) {
const media = createMediaClient({ config, fetchImpl, log });
const media = createMediaClient({ config, fetchImpl, log });
const identity = createIdentity({ config, fetchImpl, log });
const identity = createIdentity({ config, fetchImpl, log });
const drive = createService({ s, media, identity, config, log });
const drive = createService({ s, media, identity, config, log });
- const uploads = createUploads({ s, media, config, service: drive, log });
// videoUploads is made below; the account consumer only calls it when an event arrives.
// videoUploads is made below; the account consumer only calls it when an event arrives.
const account = createAccount({ db: s.db, config, media, log, onMediaVodReady: (vod) => videoUploads.importRecording(vod) });
const account = createAccount({ db: s.db, config, media, log, onMediaVodReady: (vod) => videoUploads.importRecording(vod) });
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 { createSharing, describeRef } = require('./sharing');
const { createSharing, describeRef } = require('./sharing');
- // Sharing (openvibe-sdk/share): the row next to each picture, album and video upload, and the one access check.
+ // Sharing (openvibe-sdk/share): the row next to each picture, album, video and drive upload.
const sharing = opts.sharing || createSharing({ db: s.db, config, outbox: { emit: (envelope) => eventsOutbox.emit(envelope) }, now: s.now, fetchImpl, log });
const sharing = opts.sharing || createSharing({ db: s.db, config, outbox: { emit: (envelope) => eventsOutbox.emit(envelope) }, now: s.now, fetchImpl, log });
+ const uploads = createUploads({ s, media, config, service: drive, sharing, log });
const pics = createPics({ s, media, config, log, events: eventsOutbox, sharing });
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.
@@ -257,6 +257,8 @@ async function createApp(opts = {}) {
@@ -257,6 +257,8 @@ async function createApp(opts = {}) {
{
{
const shareApi = express.Router();
const shareApi = express.Router();
shareApi.use(sso.middleware());
shareApi.use(sso.middleware());
+ // Express initializes req.body to {} on this host; parse JSON before the SDK reads it.
+ shareApi.use(express.json({ limit: '9mb' }));
shareApi.use(sharing.shares.router({
shareApi.use(sharing.shares.router({
service: 'mediahub',
service: 'mediahub',
authenticate: (req) => sharing.shareViewer(req.viewer),
authenticate: (req) => sharing.shareViewer(req.viewer),
@@ -207,10 +207,10 @@ const usageFor = (s, owner) => s.db.many(`SELECT * FROM ${USAGE} WHERE owner = $
@@ -207,10 +207,10 @@ const usageFor = (s, owner) => s.db.many(`SELECT * FROM ${USAGE} WHERE owner = $
async function insertUpload(s, row) {
async function insertUpload(s, row) {
await s.db.query(
await s.db.query(
- `INSERT INTO ${UPLOADS} (id, owner, object_id, upload_id, name, size, content_type, folder_id, part_size, parts_expected, created_at, expires_at, drop_uploader)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
+ `INSERT INTO ${UPLOADS} (id, owner, object_id, upload_id, name, size, content_type, folder_id, part_size, parts_expected, created_at, expires_at, drop_uploader, audience)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
[row.id, row.owner, row.object_id, row.upload_id, row.name, row.size, row.content_type, row.folder_id,
[row.id, row.owner, row.object_id, row.upload_id, row.name, row.size, row.content_type, row.folder_id,
- row.part_size, row.parts_expected, row.created_at, row.expires_at, row.drop_uploader || null]);
+ row.part_size, row.parts_expected, row.created_at, row.expires_at, row.drop_uploader || null, row.audience || 'private']);
return getUpload(s, row.id);
return getUpload(s, row.id);
}
}
@@ -36,11 +36,14 @@ function readSlice(file, start, length) {
@@ -36,11 +36,14 @@ function readSlice(file, start, length) {
});
});
}
}
-function createUploads({ s, media, config, service, log = console }) {
+function createUploads({ s, media, config, service, sharing, log = console }) {
const nowIso = () => new Date(s.now()).toISOString();
const nowIso = () => new Date(s.now()).toISOString();
/** Media's object is `uploading`; the session is Media's, the row is ours. */
/** Media's object is `uploading`; the session is Media's, the row is ours. */
- async function startSession({ owner, name, size, contentType, folderId = null, perDay }) {
+ async function startSession({ owner, name, size, contentType, folderId = null, perDay, audience }) {
+ if (audience !== undefined && !['public', 'link', 'people', 'private'].includes(audience)) {
+ return { ok: false, code: 'upload.audience', detail: 'Choose Everyone, Anyone with the link, Specific people, or Only me.' };
+ }
const room = await service.uploadAllowed(owner, size, perDay ? { perDay } : {});
const room = await service.uploadAllowed(owner, size, perDay ? { perDay } : {});
if (!room.ok) return room;
if (!room.ok) return room;
if (folderId && !await service.ownFolder(owner, folderId)) {
if (folderId && !await service.ownFolder(owner, folderId)) {
@@ -65,6 +68,7 @@ function createUploads({ s, media, config, service, log = console }) {
@@ -65,6 +68,7 @@ function createUploads({ s, media, config, service, log = console }) {
name, size, content_type: contentType || 'application/octet-stream', folder_id: folderId || null,
name, size, content_type: contentType || 'application/octet-stream', folder_id: folderId || null,
part_size: session.part_size, parts_expected: session.parts_expected,
part_size: session.part_size, parts_expected: session.parts_expected,
created_at: nowIso(), expires_at: new Date(s.now() + config.uploadTtlSeconds * 1000).toISOString(),
created_at: nowIso(), expires_at: new Date(s.now() + config.uploadTtlSeconds * 1000).toISOString(),
+ audience: audience || (sharing ? await sharing.defaultAudience(owner, 'download') : 'private'),
});
});
return { ok: true, upload: publicUpload(row, []) };
return { ok: true, upload: publicUpload(row, []) };
}
}
@@ -73,7 +77,7 @@ function createUploads({ s, media, config, service, log = console }) {
@@ -73,7 +77,7 @@ function createUploads({ s, media, config, service, log = console }) {
const publicUpload = (row, missing) => ({
const publicUpload = (row, missing) => ({
id: row.id, name: row.name, size: row.size, content_type: row.content_type,
id: row.id, name: row.name, size: row.size, content_type: row.content_type,
folder_id: row.folder_id, part_size: row.part_size, parts_expected: row.parts_expected,
folder_id: row.folder_id, part_size: row.part_size, parts_expected: row.parts_expected,
- expires_at: row.expires_at, missing,
+ expires_at: row.expires_at, missing, audience: row.audience,
status: row.file_id ? 'complete' : 'open',
status: row.file_id ? 'complete' : 'open',
});
});
@@ -134,6 +138,7 @@ function createUploads({ s, media, config, service, log = console }) {
@@ -134,6 +138,7 @@ function createUploads({ s, media, config, service, log = console }) {
size: Number(object.size_bytes) || row.size, contentType: object.mime_type || row.content_type,
size: Number(object.size_bytes) || row.size, contentType: object.mime_type || row.content_type,
sha256: object.content_hash || null, folderId: row.folder_id,
sha256: object.content_hash || null, folderId: row.folder_id,
});
});
+ if (sharing) await sharing.register(owner, 'download', file.id, row.audience);
await store.setUploadFile(s, row.id, file.id);
await store.setUploadFile(s, row.id, file.id);
await store.dropUpload(s, row.id);
await store.dropUpload(s, row.id);
return { ok: true, file };
return { ok: true, file };
@@ -151,8 +156,8 @@ function createUploads({ s, media, config, service, log = console }) {
@@ -151,8 +156,8 @@ function createUploads({ s, media, config, service, log = console }) {
* The whole file through the session's parts, read from a spooled file. This is the no-JavaScript path: the
* The whole file through the session's parts, read from a spooled file. This is the no-JavaScript path: the
* browser posted one request, we wrote it to disk, and Media gets it in parts exactly as it would from a script.
* browser posted one request, we wrote it to disk, and Media gets it in parts exactly as it would from a script.
*/
*/
- async function uploadSpooled({ owner, file, name, size, contentType, folderId = null }) {
- const started = await startSession({ owner, name, size, contentType, folderId });
+ async function uploadSpooled({ owner, file, name, size, contentType, folderId = null, audience }) {
+ const started = await startSession({ owner, name, size, contentType, folderId, audience });
if (!started.ok) return started;
if (!started.ok) return started;
const row = await store.getUpload(s, started.upload.id);
const row = await store.getUpload(s, started.upload.id);
try {
try {
@@ -7,7 +7,9 @@
@@ -7,7 +7,9 @@
*
*
* GET /ping anyone liveness
* GET /ping anyone liveness
* GET /files?folder_id=&limit=&before=&q= person your drive (one folder, the root, or every folder by name)
* GET /files?folder_id=&limit=&before=&q= person your drive (one folder, the root, or every folder by name)
- * POST /files person start an upload: { name, size, content_type, folder_id }
+ * POST /files person start an upload: { name, size, content_type, folder_id, audience? }
+ * GET /share-defaults/download person audience for new uploads
+ * PUT /share-defaults/download person { audience, apply? }
* GET /files/:id person one of your files
* GET /files/:id person one of your files
* PATCH /files/:id person { name?, folder_id? (null = the top) }
* PATCH /files/:id person { name?, folder_id? (null = the top) }
* DELETE /files/:id person put it in the bin (or ?forever=1: delete a binned file now)
* DELETE /files/:id person put it in the bin (or ?forever=1: delete a binned file now)
@@ -58,7 +60,7 @@ function readBody(req, cap) {
@@ -58,7 +60,7 @@ function readBody(req, cap) {
}
}
function createApi(ctx) {
function createApi(ctx) {
- const { config, s, principal, limits, drive, uploads, syncKeys, drops, media } = ctx;
+ const { config, s, principal, limits, drive, uploads, syncKeys, drops, media, sharing } = 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 });
@@ -201,6 +203,24 @@ function createApi(ctx) {
@@ -201,6 +203,24 @@ function createApi(ctx) {
// ── Liveness ────────────────────────────────────────────
// ── Liveness ────────────────────────────────────────────
r.get('/ping', limits.reads('media-hub.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
r.get('/ping', limits.reads('media-hub.api.read'), (_req, res) => res.json({ ok: true, service: config.service }));
+ r.get('/share-defaults/:kind', person, async (req, res) => {
+ const kind = String(req.params.kind || '');
+ if (kind !== 'download') return problem(req, res, 404, 'share.kind', 'No such share kind.');
+ return res.json({ kind, audience: await sharing.defaultAudience(personOf(req), kind) });
+ });
+
+ r.put('/share-defaults/:kind', person, async (req, res) => {
+ const kind = String(req.params.kind || '');
+ const audience = String((req.body || {}).audience || '');
+ if (kind !== 'download' || !['public', 'link', 'people', 'private'].includes(audience)) {
+ return problem(req, res, 422, 'share.default_invalid', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');
+ }
+ const owner = personOf(req);
+ await sharing.setDefaultAudience(owner, kind, audience);
+ const changed = req.body && req.body.apply === true ? await sharing.applyDefault(owner, kind, audience) : 0;
+ return res.json({ kind, audience, changed });
+ });
+
// ── Files ───────────────────────────────────────────────
// ── Files ───────────────────────────────────────────────
r.get('/files', person, limits.reads('media-hub.file.read'), async (req, res) => {
r.get('/files', person, limits.reads('media-hub.file.read'), async (req, res) => {
const owner = personOf(req);
const owner = personOf(req);
@@ -238,8 +258,9 @@ function createApi(ctx) {
@@ -238,8 +258,9 @@ function createApi(ctx) {
const contentType = rules.normalizeType(b.content_type) || 'application/octet-stream';
const contentType = rules.normalizeType(b.content_type) || 'application/octet-stream';
const folderId = b.folder_id ? String(b.folder_id) : null;
const folderId = b.folder_id ? String(b.folder_id) : null;
if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');
if (folderId && !rules.isFolderId(folderId)) return problem(req, res, 422, 'folder.invalid', 'folder_id must be a folder id.');
+ if (req.principal.viaSyncKey && b.audience && b.audience !== 'private') return problem(req, res, 403, 'sync_key.not_allowed', 'A sync key cannot share a backup.');
const perDay = req.principal.viaSyncKey ? config.quotas.syncUploadsPerDay : undefined;
const perDay = req.principal.viaSyncKey ? config.quotas.syncUploadsPerDay : undefined;
- const out = await uploads.startSession({ owner, name, size, contentType, folderId, perDay });
+ const out = await uploads.startSession({ owner, name, size, contentType, folderId, perDay, audience: req.principal.viaSyncKey ? 'private' : b.audience });
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
res.set('Location', `/api/v1/uploads/${out.upload.id}`);
res.set('Location', `/api/v1/uploads/${out.upload.id}`);
return res.status(201).json({
return res.status(201).json({
@@ -66,6 +66,25 @@ route('get', '/ping', {
@@ -66,6 +66,25 @@ route('get', '/ping', {
responses: { 200: json('The service answers', { type: 'object', properties: { ok: { const: true }, service: str() } }), ...errors([429]) },
responses: { 200: json('The service answers', { type: 'object', properties: { ok: { const: true }, service: str() } }), ...errors([429]) },
});
});
+// ── Download audience default ─────────────────────────────────────────────────────────────────
+route('get', '/share-defaults/{kind}', {
+ operationId: 'getShareDefault', summary: 'Audience for new Download uploads', tags: ['shares'], security: PERSON,
+ parameters: [path('kind', str({ enum: ['download'] }))],
+ responses: { 200: json('The saved default, or Everyone when none is saved', { type: 'object', properties: {
+ kind: str(), audience: str({ enum: ['public', 'link', 'people', 'private'] }),
+ } }), ...errors([401, 404, 429]) },
+});
+route('put', '/share-defaults/{kind}', {
+ operationId: 'setShareDefault', summary: 'Save a Download audience default and optionally apply it to existing files', tags: ['shares'], security: PERSON,
+ parameters: [path('kind', str({ enum: ['download'] }))],
+ requestBody: body({ type: 'object', required: ['audience'], properties: {
+ audience: str({ enum: ['public', 'link', 'people', 'private'] }), apply: { type: 'boolean' },
+ } }),
+ responses: { 200: json('The default and number of existing files changed', { type: 'object', properties: {
+ kind: str(), audience: str(), changed: integer(),
+ } }), ...errors([401, 403, 422, 429]) },
+});
+
// ── Files ──────────────────────────────────────────────────────────────────────────────────────
// ── Files ──────────────────────────────────────────────────────────────────────────────────────
route('get', '/files', {
route('get', '/files', {
operationId: 'listFiles', summary: 'One folder of your drive (or the root), or a search of every folder', tags: ['files'], security: PERSON,
operationId: 'listFiles', summary: 'One folder of your drive (or the root), or a search of every folder', tags: ['files'], security: PERSON,
@@ -80,6 +99,7 @@ route('post', '/files', {
@@ -80,6 +99,7 @@ route('post', '/files', {
description: 'A signed-in session write must come from openvibe.download itself. 201 carries Location: /api/v1/uploads/{id}.',
description: 'A signed-in session write must come from openvibe.download itself. 201 carries Location: /api/v1/uploads/{id}.',
requestBody: body({ type: 'object', required: ['name', 'size'], properties: {
requestBody: body({ type: 'object', required: ['name', 'size'], properties: {
name: str(), size: integer({ minimum: 0 }), content_type: str(), folder_id: str(),
name: str(), size: integer({ minimum: 0 }), content_type: str(), folder_id: str(),
+ audience: str({ enum: ['public', 'link', 'people', 'private'], description: 'Overrides the saved default for this upload. A sync key can use Only me only.' }),
} }),
} }),
responses: { 201: json('The upload session and what is still missing', { type: 'object', properties: { upload: { type: 'object' }, note: str() } }), ...errors() },
responses: { 201: json('The upload session and what is still missing', { type: 'object', properties: { upload: { type: 'object' }, note: str() } }), ...errors() },
});
});
@@ -80,7 +80,7 @@ function driveScene() {
@@ -80,7 +80,7 @@ function driveScene() {
}
}
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config, s, drive, uploads, media, syncKeys, drops } = ctx;
+ const { config, s, drive, uploads, media, syncKeys, drops, sharing } = ctx;
const r = asyncRouter();
const r = asyncRouter();
// The pages' forms are web forms (no JavaScript): urlencoded text, and one multipart file upload whose body the
// The pages' forms are web forms (no JavaScript): urlencoded text, and one multipart file upload whose body the
// upload route reads itself. The JSON parser is deliberately absent here.
// upload route reads itself. The JSON parser is deliberately absent here.
@@ -310,10 +310,11 @@ ${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is
@@ -310,10 +310,11 @@ ${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is
</nav>`;
</nav>`;
/** The upload: a big "Upload files" target over a plain file form. upload.js takes it over (many files, the tray). */
/** The upload: a big "Upload files" target over a plain file form. upload.js takes it over (many files, the tray). */
- const uploadForm = (folder) => html`<form class="dr-upload" method="post" action="/files/upload" enctype="multipart/form-data" data-upload>
+ const uploadForm = (folder, audience) => html`<form class="dr-upload" method="post" action="/files/upload" enctype="multipart/form-data" data-upload>
${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
<input id="file" class="dr-pick-in" name="file" type="file" required>
<input id="file" class="dr-pick-in" name="file" type="file" required>
<label class="dr-pick" for="file">${ui.icon('upload')}<span><b>Upload files</b><small>Or drop them anywhere on this page</small></span></label>
<label class="dr-pick" for="file">${ui.icon('upload')}<span><b>Upload files</b><small>Or drop them anywhere on this page</small></span></label>
+<label>Who can open a new file <select name="audience"><option value="public"${audience === 'public' ? raw(' selected') : ''}>Everyone</option><option value="link"${audience === 'link' ? raw(' selected') : ''}>Anyone with the link</option><option value="people"${audience === 'people' ? raw(' selected') : ''}>Specific people</option><option value="private"${audience === 'private' ? raw(' selected') : ''}>Only me</option></select></label>
<div class="dr-upload-go" data-nojs><input id="name" name="name" type="text" maxlength="${rules.NAME_MAX}" placeholder="Name (optional)" aria-label="Name (optional)"><button class="dr-btn is-primary" type="submit">Upload</button></div>
<div class="dr-upload-go" data-nojs><input id="name" name="name" type="text" maxlength="${rules.NAME_MAX}" placeholder="Name (optional)" aria-label="Name (optional)"><button class="dr-btn is-primary" type="submit">Upload</button></div>
<progress id="upload-progress" max="100" value="0" hidden></progress>
<progress id="upload-progress" max="100" value="0" hidden></progress>
<p id="upload-status" class="dr-fine" role="status"></p>
<p id="upload-status" class="dr-fine" role="status"></p>
@@ -356,6 +357,7 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
@@ -356,6 +357,7 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
}
}
const usage = await drive.usage(owner);
const usage = await drive.usage(owner);
const binCount = await drive.binCount(owner);
const binCount = await drive.binCount(owner);
+ const shareDefault = await sharing.defaultAudience(owner, 'download');
const title = q ? `“${q}”` : folder ? folder.name : 'My files';
const title = q ? `“${q}”` : folder ? folder.name : 'My files';
const empty = !files.length && !folders.length;
const empty = !files.length && !folders.length;
const keep = (extra) => html`${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}${extra || ''}`;
const keep = (extra) => html`${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}${extra || ''}`;
@@ -370,7 +372,8 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
@@ -370,7 +372,8 @@ ${folder ? html`<input type="hidden" name="folder" value="${folder.id}">` : ''}
<details class="dr-new" data-new><summary class="dr-new-btn">${ui.icon('plus')}<span>New</span></summary>
<details class="dr-new" data-new><summary class="dr-new-btn">${ui.icon('plus')}<span>New</span></summary>
<div class="dr-new-panel" role="group" aria-label="Add to ${folder ? folder.name : 'My files'}">
<div class="dr-new-panel" role="group" aria-label="Add to ${folder ? folder.name : 'My files'}">
<p class="dr-new-title">Add to <b>${folder ? folder.name : 'My files'}</b></p>
<p class="dr-new-title">Add to <b>${folder ? folder.name : 'My files'}</b></p>
-${uploadForm(folder)}
+${uploadForm(folder, shareDefault)}
+<form method="post" action="/files/share-default"><label>New files <select name="audience"><option value="public"${shareDefault === 'public' ? raw(' selected') : ''}>Everyone</option><option value="link"${shareDefault === 'link' ? raw(' selected') : ''}>Anyone with the link</option><option value="people"${shareDefault === 'people' ? raw(' selected') : ''}>Specific people</option><option value="private"${shareDefault === 'private' ? raw(' selected') : ''}>Only me</option></select></label><label><input type="checkbox" name="apply" value="1"> Apply to all my existing files</label><button class="ovk-btn ovk-btn--quiet" type="submit">Save default</button></form>
${folderForm(folder)}
${folderForm(folder)}
<a class="dr-new-row" href="/sync">${ui.icon('sync')}<span><b>Back up a folder</b><small>Keep a folder on your computer copied here</small></span></a>
<a class="dr-new-row" href="/sync">${ui.icon('sync')}<span><b>Back up a folder</b><small>Keep a folder on your computer copied here</small></span></a>
</div></details>
</div></details>
@@ -441,10 +444,37 @@ ${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.fi
@@ -441,10 +444,37 @@ ${files.length ? html`<ul class="dr-files" data-items>${files.map((row) => ui.fi
});
});
});
});
- r.get('/files/:id', mustSignIn('/files'), async (req, res) => {
+ r.post('/files/share-default', mustSignIn('/files'), async (req, res) => {
+ if (!sameSite(req, res, '/files')) return undefined;
+ const audience = String((req.body || {}).audience || '');
+ if (!['public', 'link', 'people', 'private'].includes(audience)) return res.status(422).type('text/plain').send('Choose Everyone, Anyone with the link, Specific people, or Only me.');
const owner = requesterOf(req);
const owner = requesterOf(req);
- const row = await drive.ownFile(owner, String(req.params.id || ''));
+ await sharing.setDefaultAudience(owner, 'download', audience);
+ if (req.body && req.body.apply === '1') await sharing.applyDefault(owner, 'download', audience);
+ return res.redirect(303, '/files');
+ });
+
+ r.get('/files/:id', async (req, res) => {
+ const id = String(req.params.id || '');
+ const row = rules.isFileId(id) ? await ctx.store.getFile(s, id) : null;
+ const mine = row && signedIn(req) && row.owner === requesterOf(req);
+ if (row && (row.deleted_at || row.binned_at)) return notFoundFile(req, res);
+ if (row && !mine && !await sharing.canView(req.viewer, 'download', row.id)) {
+ const share = await sharing.shareOf('download', row.id);
+ if (share && share.audience === 'people') return page(req, res, {
+ title: 'A file on OpenVibe.Download', noindex: true,
+ body: html`<h1>A file on OpenVibe.Download</h1><p>This is shared with specific people.</p>
+<div data-ov-share-gate data-kind="download" data-id="${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>Sign in to ask for access.</p></div>
+<link rel="stylesheet" href="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`,
+ }, 404);
+ return notFoundFile(req, res);
+ }
if (!row) return notFoundFile(req, res);
if (!row) return notFoundFile(req, res);
+ if (!mine) return page(req, res, {
+ title: row.name,
+ body: html`<h1>${row.name}</h1><p>${fileSize(row)} · ${row.content_type}</p><p><a class="ovk-btn ovk-btn--primary" href="/files/${row.id}/download">Download</a> ${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name }))}</p>`,
+ });
+ const owner = requesterOf(req);
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 agentGrants = await drive.listAgentGrants(owner, row.id);
const allFolders = await drive.listFolders(owner);
const allFolders = await drive.listFolders(owner);
@@ -470,7 +500,7 @@ ${pathBar(path, row.name)}
@@ -470,7 +500,7 @@ ${pathBar(path, row.name)}
<div class="fv-side">
<div class="fv-side">
<h1 class="fv-name">${row.name}</h1>
<h1 class="fv-name">${row.name}</h1>
<p class="fv-sub"><span class="fv-kind" data-kind="${kind}">${ui.icon(ui.KINDS[kind].icon)}${ui.kindWord(row)}</span><span>${fileSize(row)}</span><span>Added ${ui.when(row.created_at)}</span></p>
<p class="fv-sub"><span class="fv-kind" data-kind="${kind}">${ui.icon(ui.KINDS[kind].icon)}${ui.kindWord(row)}</span><span>${fileSize(row)}</span><span>Added ${ui.when(row.created_at)}</span></p>
-<div class="fv-actions"><a class="dr-btn is-primary" href="/files/${row.id}/download">${ui.icon('download')}Download</a>${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: pic ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '', cls: 'dr-btn' }))}${raw(askAgent({ text: `About my file “${row.name}” (${ui.kindWord(row)}, ${fileSize(row)}): ` }))}</div>
+<div class="fv-actions"><a class="dr-btn is-primary" href="/files/${row.id}/download">${ui.icon('download')}Download</a>${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: pic ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '', cls: 'dr-btn', kind: 'download', id: row.id }))}${raw(askAgent({ text: `About my file “${row.name}” (${ui.kindWord(row)}, ${fileSize(row)}): ` }))}</div>
${/^audio\/(mpeg|mp4|ogg|webm|wav|flac)$/.test(row.content_type) ? html`<section class="fv-card"><h2>OpenVibe Radio</h2>${radioTrack
${/^audio\/(mpeg|mp4|ogg|webm|wav|flac)$/.test(row.content_type) ? html`<section class="fv-card"><h2>OpenVibe Radio</h2>${radioTrack
? html`<p>This music is published for listening parties.</p><a class="dr-btn" href="/radio/tracks/${radioTrack.id}">Open in Radio</a>`
? html`<p>This music is published for listening parties.</p><a class="dr-btn" href="/radio/tracks/${radioTrack.id}">Open in Radio</a>`
: html`<p>Mark this audio file as music and choose a licence before anyone can listen in a party.</p><a class="dr-btn" href="/radio/publish?file=${row.id}">Publish as music</a>`}</section>` : ''}
: html`<p>Mark this audio file as music and choose a licence before anyone can listen in a party.</p><a class="dr-btn" href="/radio/publish?file=${row.id}">Publish as music</a>`}</section>` : ''}
@@ -564,6 +594,7 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul
@@ -564,6 +594,7 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul
name: rules.fileName(got.fields.name || got.file.filename, got.file.filename),
name: rules.fileName(got.fields.name || got.file.filename, got.file.filename),
contentType: rules.normalizeType(got.file.contentType) || 'application/octet-stream',
contentType: rules.normalizeType(got.file.contentType) || 'application/octet-stream',
folderId: folder ? folder.id : null,
folderId: folder ? folder.id : null,
+ audience: got.fields.audience,
});
});
// A refusal by our own rules is shown as it came; a Media outage is one sentence, not Media's own words.
// A refusal by our own rules is shown as it came; a Media outage is one sentence, not Media's own words.
if (!out.ok) return uploadFailed(req, res, String(out.code).startsWith('media.')
if (!out.ok) return uploadFailed(req, res, String(out.code).startsWith('media.')
@@ -790,13 +821,21 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul
@@ -790,13 +821,21 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul
return undefined;
return undefined;
}
}
- r.get('/files/:id/preview', mustSignIn('/files'), async (req, res) => {
- const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));
+ async function fileForRead(req) {
+ const id = String(req.params.id || '');
+ const row = rules.isFileId(id) ? await ctx.store.getFile(s, id) : null;
+ if (!row || row.deleted_at || row.binned_at) return null;
+ if (signedIn(req) && row.owner === requesterOf(req)) return row;
+ return await sharing.canView(req.viewer, 'download', row.id) ? row : null;
+ }
+
+ r.get('/files/:id/preview', async (req, res) => {
+ const row = await fileForRead(req);
return sendPreview(req, res, row);
return sendPreview(req, res, row);
});
});
- r.get('/files/:id/download', mustSignIn('/files'), async (req, res) => {
- const row = await drive.ownFile(requesterOf(req), String(req.params.id || ''));
+ r.get('/files/:id/download', async (req, res) => {
+ const row = await fileForRead(req);
if (!row) return notFoundFile(req, res);
if (!row) return notFoundFile(req, res);
return sendFile(req, res, row);
return sendFile(req, res, row);
});
});
@@ -836,6 +875,13 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul
@@ -836,6 +875,13 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul
}, 403);
}, 403);
return null;
return null;
}
}
+ if (share.file_id) {
+ const file = await fileById(share);
+ if (file && file.share_v1 && !await sharing.canView(req.viewer, 'download', file.id)) {
+ notFoundFile(req, res);
+ return null;
+ }
+ }
return share;
return share;
}
}
@@ -877,7 +923,8 @@ ${mine ? '' : reportForm(share)}
@@ -877,7 +923,8 @@ ${mine ? '' : reportForm(share)}
<p class="small muted">${REPORT_NOTE}</p>`,
<p class="small muted">${REPORT_NOTE}</p>`,
});
});
}
}
- const files = await drive.listFiles(share.owner, { folderId: share.folder_id, limit: 200 });
+ const files = (await Promise.all((await drive.listFiles(share.owner, { folderId: share.folder_id, limit: 200 }))
+ .map(async (f) => !f.share_v1 || await sharing.canView(req.viewer, 'download', f.id) ? f : null))).filter(Boolean);
const folder = await drive.ownFolder(share.owner, share.folder_id);
const folder = await drive.ownFolder(share.owner, share.folder_id);
return page(req, res, {
return page(req, res, {
title: `${folder ? folder.name : 'A folder'} · shared by ${share.owner_username || 'an OpenVibe member'}`,
title: `${folder ? folder.name : 'A folder'} · shared by ${share.owner_username || 'an OpenVibe member'}`,
@@ -933,6 +980,7 @@ ${mine ? '' : reportForm(share)}`,
@@ -933,6 +980,7 @@ ${mine ? '' : reportForm(share)}`,
body: html`<h1>No such file in this share</h1><p class="muted"><a href="/s/${share.slug}">The share page</a>.</p>`,
body: html`<h1>No such file in this share</h1><p class="muted"><a href="/s/${share.slug}">The share page</a>.</p>`,
}, 404);
}, 404);
}
}
+ if (file.share_v1 && !await sharing.canView(req.viewer, 'download', file.id)) return notFoundFile(req, res);
await ctx.store.countDownload(s, share.slug);
await ctx.store.countDownload(s, share.slug);
return sendFile(req, res, file);
return sendFile(req, res, file);
});
});
@@ -16,14 +16,14 @@
@@ -16,14 +16,14 @@
* always unlisted and never indexed, so Everyone here means "anyone with the link", exactly as the
* 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.
* product already worked; Only me and Specific people are the new, narrower choices.
*/
*/
-const { createShares } = require('openvibe-sdk/share');
+const { createShares, ShareError } = require('openvibe-sdk/share');
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const SERVICE = 'mediahub';
const SERVICE = 'mediahub';
const KINDS = ['picture', 'album', 'video', 'download'];
const KINDS = ['picture', 'album', 'video', 'download'];
const AUDIENCES = ['public', 'link', 'people', 'private'];
const AUDIENCES = ['public', 'link', 'people', 'private'];
/** MediaHub's thread words to the SDK's (there is no column to keep in step). */
/** 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 TO_SDK = { public: 'public', link: 'link', unlisted: 'link', people: 'people', private: 'private' };
const FROM_SDK = { public: 'public', link: 'unlisted', people: 'private', 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) });
const refOf = (kind, id) => ({ service: SERVICE, kind: String(kind), id: String(id) });
@@ -104,15 +104,54 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
@@ -104,15 +104,54 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
} else if (ref.kind === 'video') {
} else if (ref.kind === 'video') {
const vis = audience === 'public' ? 'public' : audience === 'link' ? 'unlisted' : 'private';
const vis = audience === 'public' ? 'public' : audience === 'link' ? 'unlisted' : 'private';
await tx.exec('UPDATE video_uploads SET visibility = $1 WHERE id = $2', [vis, ref.id]);
await tx.exec('UPDATE video_uploads SET visibility = $1 WHERE id = $2', [vis, ref.id]);
+ } else if (ref.kind === 'download') {
+ await tx.exec('UPDATE mh_files SET share_v1 = true WHERE id = $1', [ref.id]);
+ if (audience === 'people' || audience === 'private') {
+ // An expiring legacy file or folder link must not bypass a narrower Share choice.
+ await tx.exec(`UPDATE mh_shares SET revoked_at = coalesce(revoked_at, now()::text)
+ WHERE (file_id = $1 OR folder_id = (SELECT folder_id FROM mh_files WHERE id = $1)) AND revoked_at IS NULL`, [ref.id]);
+ await tx.exec('UPDATE mh_agent_grants SET revoked_at = coalesce(revoked_at, now()::text) WHERE file_id = $1 AND revoked_at IS NULL', [ref.id]);
+ await tx.exec('UPDATE mh_radio_tracks SET revoked_at = coalesce(revoked_at, now()) WHERE file_id = $1 AND revoked_at IS NULL', [ref.id]);
+ }
}
}
// After commit, let the service re-send Search what it may keep (a tombstone once a video leaves Everyone).
// 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 {} });
if (changedHook && tx.afterCommit) tx.afterCommit(() => { try { changedHook(ref, audience); } catch {} });
},
},
});
});
+ // Media binds drive bytes to their original owner. Moving only this database row would let that person's
+ // later account deletion remove a file someone else now owns, so reject a transfer until Media can move it too.
+ const rawTransfer = shares.transfer.bind(shares);
+ shares.transfer = (viewer, ref, input) => {
+ if (ref.kind === 'download') throw new ShareError(409, 'share.transfer_unavailable', 'This file cannot be transferred yet');
+ return rawTransfer(viewer, ref, input);
+ };
const sv = (viewer) => shareViewer(viewer);
const sv = (viewer) => shareViewer(viewer);
const at = (kind, id) => refOf(kind, id);
const at = (kind, id) => refOf(kind, id);
+ async function defaultAudience(owner, kind) {
+ if (kind !== 'download') throw new Error('Unknown share kind');
+ const row = await db.maybe('SELECT audience FROM mh_share_defaults WHERE owner = $1 AND kind = $2', [owner, kind]);
+ return row ? row.audience : 'public';
+ }
+ async function setDefaultAudience(owner, kind, audience) {
+ if (kind !== 'download' || !AUDIENCES.includes(audience)) throw new Error('Invalid share default');
+ await db.exec(`INSERT INTO mh_share_defaults (owner, kind, audience) VALUES ($1, $2, $3)
+ ON CONFLICT (owner, kind) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [owner, kind, audience]);
+ }
+ async function applyDefault(owner, kind, audience) {
+ if (kind !== 'download' || !AUDIENCES.includes(audience)) throw new Error('Invalid share default');
+ const rows = await db.many('SELECT id FROM mh_files WHERE owner = $1 AND deleted_at IS NULL AND binned_at IS NULL', [owner]);
+ const viewer = { subject: subjectOf(owner) };
+ let changed = 0;
+ for (const row of rows) {
+ const current = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, kind, row.id]);
+ if (!current) { await shares.register(at(kind, row.id), { owner_subject: subjectOf(owner), audience }); changed++; continue; }
+ if (current && current.audience !== audience) { await shares.setAudience(viewer, at(kind, row.id), audience); changed++; }
+ }
+ return changed;
+ }
+
return {
return {
shares,
shares,
SERVICE, KINDS, AUDIENCES, TO_SDK, FROM_SDK, refOf, shareViewer, subjectOf,
SERVICE, KINDS, AUDIENCES, TO_SDK, FROM_SDK, refOf, shareViewer, subjectOf,
@@ -121,6 +160,7 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
@@ -121,6 +160,7 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
owner_subject: subjectOf(requester) || { type: 'service', id: SERVICE },
owner_subject: subjectOf(requester) || { type: 'service', id: SERVICE },
audience: TO_SDK[audience] || 'public',
audience: TO_SDK[audience] || 'public',
}),
}),
+ defaultAudience, setDefaultAudience, applyDefault,
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)]),
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)),
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)),
canEdit: async (viewer, kind, id) => await shares.canEdit(sv(viewer), at(kind, id)),
@@ -170,6 +210,11 @@ function describeRef(db, config) {
@@ -170,6 +210,11 @@ function describeRef(db, config) {
if (!row) return {};
if (!row) return {};
return { title: row.title || 'A video', link: `${base}${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };
return { title: row.title || 'A video', link: `${base}${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };
}
}
+ if (ref.kind === 'download') {
+ const row = await db.maybe('SELECT id, name FROM mh_files WHERE id = $1 AND deleted_at IS NULL AND binned_at IS NULL', [ref.id]);
+ if (!row) return {};
+ return { title: row.name, link: `${base}/files/${row.id}`, roles: ['viewer'], audiences: AUDIENCES, transferable: false };
+ }
return {};
return {};
};
};
}
}
@@ -75,6 +75,7 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -75,6 +75,7 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
});
});
await check('the export carries the person\'s files, folders, shares, reports and upload counts', async () => {
await check('the export carries the person\'s files, folders, shares, reports and upload counts', async () => {
+ await t.ctx.sharing.setDefaultAudience(`user:${kim.subject}`, 'download', 'private');
const r = await deliver(envelope('network.account.export_requested', { export_id: EXPORT, subject: kim.subject }));
const r = await deliver(envelope('network.account.export_requested', { export_id: EXPORT, subject: kim.subject }));
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.strictEqual(r.json().outcome, 'exported');
assert.strictEqual(r.json().outcome, 'exported');
@@ -85,7 +86,9 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -85,7 +86,9 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
assert.ok(files, 'a files.json part');
assert.ok(files, 'a files.json part');
assert.strictEqual(files.content.length, 1);
assert.strictEqual(files.content.length, 1);
assert.strictEqual(files.content[0].id, file.id);
assert.strictEqual(files.content[0].id, file.id);
- assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['files.json', 'radio_tracks.json', 'reports.json', 'shares.json', 'usage.json', 'video_likes.json'], 'and the rest of the person\'s own data');
+ assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['files.json', 'radio_tracks.json', 'reports.json', 'share-defaults.json', 'shares.json', 'uniform-sharing.json', 'usage.json', 'video_likes.json'], 'and the rest of the person\'s own data');
+ assert.strictEqual(part.files.find((f) => f.name === 'share-defaults.json').content[0].audience, 'private');
+ assert.ok(part.files.find((f) => f.name === 'uniform-sharing.json').content.owned.some((s) => s.kind === 'download' && s.resource_id === file.id));
assert.deepStrictEqual(part.files.find((f) => f.name === 'radio_tracks.json').content.map((track) => track.id), ['radio_kim'], 'a person can export their radio publications');
assert.deepStrictEqual(part.files.find((f) => f.name === 'radio_tracks.json').content.map((track) => track.id), ['radio_kim'], 'a person can export their radio publications');
assert.deepStrictEqual(part.files.find((f) => f.name === 'video_likes.json').content.map((like) => like.item_id), [101], 'a person can export their likes');
assert.deepStrictEqual(part.files.find((f) => f.name === 'video_likes.json').content.map((like) => like.item_id), [101], 'a person can export their likes');
assert.ok(part.files.find((f) => f.name === 'shares.json').content.some((s) => s.slug === slug));
assert.ok(part.files.find((f) => f.name === 'shares.json').content.some((s) => s.slug === slug));
@@ -112,6 +115,7 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -112,6 +115,7 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
assert.strictEqual(confirmation.subject, kim.subject);
assert.strictEqual(confirmation.subject, kim.subject);
assert.ok(confirmation.erased.mh_files >= 1, JSON.stringify(confirmation));
assert.ok(confirmation.erased.mh_files >= 1, JSON.stringify(confirmation));
assert.ok(confirmation.erased.mh_shares >= 1, JSON.stringify(confirmation));
assert.ok(confirmation.erased.mh_shares >= 1, JSON.stringify(confirmation));
+ assert.strictEqual(confirmation.erased.mh_share_defaults, 1, 'the saved share default is erased');
assert.strictEqual(confirmation.erased.mh_radio_tracks, 1, 'radio publications are erased with the account');
assert.strictEqual(confirmation.erased.mh_radio_tracks, 1, 'radio publications are erased with the account');
assert.strictEqual(confirmation.erased.video_likes, 1, 'likes are erased with the account');
assert.strictEqual(confirmation.erased.video_likes, 1, 'likes are erased with the account');
assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM mh_radio_tracks WHERE owner = $1', [`user:${sam.subject}`])), 1, 'another person\'s radio publication remains');
assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*) FROM mh_radio_tracks WHERE owner = $1', [`user:${sam.subject}`])), 1, 'another person\'s radio publication remains');
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, bytes, upload, share } = require('./helpers/drive');
+
+(async () => {
+ const t = await boot();
+ const owner = t.network.addUser('owner');
+ const reader = t.network.addUser('reader');
+ const outsider = t.network.addUser('outsider');
+ const putShare = (id, body) => t.get(`/api/v1/download/${id}/share`, {
+ as: owner, method: 'PUT', json: body, headers: { ...SAME, 'x-requested-with': 'ov-share' },
+ });
+ try {
+ await check('new downloads have a share row and the upload audience controls page and bytes', async () => {
+ const data = bytes(350, 'open');
+ const made = await upload(t, owner, data, { name: 'open.txt' });
+ assert.strictEqual(made.status, 201, made.text);
+ const id = made.json().file.id;
+ const row = await t.ctx.sharing.shareOf('download', id);
+ assert.strictEqual(row.audience, 'public');
+ assert.strictEqual((await t.get(`/files/${id}`)).status, 200);
+ assert.ok((await t.get(`/files/${id}/download`)).buffer.equals(data));
+ const ownPage = await t.get(`/files/${id}`, { as: owner });
+ assert.ok(ownPage.text.includes(`data-kind="download" data-id="${id}"`));
+ const transfer = await t.get(`/api/v1/download/${id}/share/transfer`, {
+ as: owner, json: { to: { type: 'user', id: reader.subject }, keep_access: false }, headers: SAME,
+ });
+ assert.strictEqual(transfer.status, 409, transfer.text);
+
+ const privateFile = (await upload(t, owner, bytes(360, 'private'), { name: 'private.txt', audience: 'private' })).json().file.id;
+ assert.strictEqual((await t.get(`/files/${privateFile}`)).status, 404);
+ assert.strictEqual((await t.get(`/files/${privateFile}/download`, { as: outsider })).status, 404);
+ assert.strictEqual((await t.get(`/files/${privateFile}`, { as: owner })).status, 200);
+ const linkFile = (await upload(t, owner, bytes(370, 'link'), { audience: 'link' })).json().file.id;
+ assert.strictEqual((await t.get(`/files/${linkFile}`)).status, 200);
+ });
+
+ await check('Specific people gate both the page and bytes, and narrowing revokes old links', async () => {
+ const data = bytes(420, 'named');
+ const id = (await upload(t, owner, data, { name: 'named.txt' })).json().file.id;
+ const legacy = (await share(t, owner, { file_id: id, public: true })).json().share.slug;
+ const narrowed = await putShare(id, { audience: 'people', members: [{ subject: { type: 'user', id: reader.subject }, role: 'viewer' }] });
+ assert.strictEqual(narrowed.status, 200, narrowed.text);
+ assert.strictEqual((await t.get(`/files/${id}`)).status, 404);
+ const denied = await t.get(`/files/${id}`, { as: outsider });
+ assert.strictEqual(denied.status, 404);
+ assert.ok(denied.text.includes('data-ov-share-gate') && !denied.text.includes('named.txt'));
+ assert.strictEqual((await t.get(`/files/${id}/download`, { as: outsider })).status, 404);
+ assert.strictEqual((await t.get(`/files/${id}`, { as: reader })).status, 200);
+ assert.ok((await t.get(`/files/${id}/download`, { as: reader })).buffer.equals(data));
+ assert.strictEqual((await t.get(`/s/${legacy}`)).status, 404);
+ });
+
+ await check('a shared folder hides and does not count a private new file', async () => {
+ const folder = (await t.get('/api/v1/folders', { as: owner, json: { name: 'shared' }, headers: SAME })).json().folder;
+ const id = (await upload(t, owner, bytes(430, 'folder secret'), { name: 'folder-secret.txt', folder: folder.id, audience: 'private' })).json().file.id;
+ const slug = (await share(t, owner, { folder_id: folder.id, public: true })).json().share.slug;
+ const page = await t.get(`/s/${slug}`);
+ assert.strictEqual(page.status, 200, page.text);
+ assert.ok(!page.text.includes('folder-secret.txt') && page.text.includes('0 files'));
+ assert.strictEqual((await t.get(`/s/${slug}/f/${id}/download`)).status, 404);
+ });
+
+ await check('default plus apply to all changes only the owner’s files; upload may override it', async () => {
+ const a = (await upload(t, owner, bytes(440, 'a'))).json().file.id;
+ const b = (await upload(t, outsider, bytes(450, 'b'))).json().file.id;
+ const saved = await t.get('/api/v1/share-defaults/download', { as: owner, method: 'PUT', json: { audience: 'private', apply: true }, headers: SAME });
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.ok(saved.json().changed >= 1);
+ assert.strictEqual((await t.get(`/files/${a}`)).status, 404);
+ assert.strictEqual((await t.get(`/files/${b}`)).status, 200);
+ const next = (await upload(t, owner, bytes(460, 'next'))).json().file.id;
+ assert.strictEqual((await t.ctx.sharing.shareOf('download', next)).audience, 'private');
+ const override = (await upload(t, owner, bytes(470, 'override'), { audience: 'public' })).json().file.id;
+ assert.strictEqual((await t.get(`/files/${override}`)).status, 200);
+ const namedDefault = await t.get('/api/v1/share-defaults/download', { as: owner, method: 'PUT', json: { audience: 'people' }, headers: SAME });
+ assert.strictEqual(namedDefault.status, 200, namedDefault.text);
+ const named = (await upload(t, owner, bytes(480, 'named-default'))).json().file.id;
+ assert.strictEqual((await t.ctx.sharing.shareOf('download', named)).audience, 'people');
+ assert.strictEqual((await t.get(`/files/${named}`)).status, 404);
+ assert.strictEqual((await t.get(`/files/${named}`, { as: owner })).status, 200);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -96,12 +96,12 @@ const { SAME, bytes, sha256, upload, share } = require('./helpers/drive');
@@ -96,12 +96,12 @@ const { SAME, bytes, sha256, upload, share } = require('./helpers/drive');
assert.strictEqual(part.headers.get('content-disposition'), `attachment; filename="slice.bin"; filename*=UTF-8''slice.bin`);
assert.strictEqual(part.headers.get('content-disposition'), `attachment; filename="slice.bin"; filename*=UTF-8''slice.bin`);
});
});
- await check('nothing is served to anybody who is not signed in', async () => {
- const file = await put('guarded.bin', bytes(30, 'guarded'), 'application/octet-stream');
+ await check('a file chosen as Only me sends no bytes to a guest; an ordinary expiring link still asks for sign-in', async () => {
+ const file = (await upload(t, kim, bytes(30, 'guarded'), { name: 'guarded.bin', audience: 'private' })).json().file;
const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
const slug = (await share(t, kim, { file_id: file.id, hours: 24 })).json().share.slug;
- for (const p of [`/files/${file.id}/download`, `/s/${slug}/download`]) {
+ for (const [p, status] of [[`/files/${file.id}/download`, 404], [`/s/${slug}/download`, 401]]) {
const r = await t.get(p);
const r = await t.get(p);
- assert.strictEqual(r.status, 401, p);
+ assert.strictEqual(r.status, status, p);
assert.ok(!r.buffer.includes(Buffer.from('guarded')), `${p}: no bytes to a guest`);
assert.ok(!r.buffer.includes(Buffer.from('guarded')), `${p}: no bytes to a guest`);
}
}
});
});
@@ -14,14 +14,15 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
@@ -14,14 +14,15 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
const kim = t.network.addUser('kim');
const kim = t.network.addUser('kim');
const sam = t.network.addUser('sam');
const sam = t.network.addUser('sam');
- await check('a guest gets a sign-in page for every file page, never a file', async () => {
- for (const p of ['/files', '/files/bin', '/files/fil_01JABCDEFGHJKMNPQRSTVWXYZ0', '/staff', '/s/abcdefghijklmnopqrstuvwx']) {
+ await check('private drive pages ask a guest to sign in; a missing file reveals nothing', async () => {
+ for (const p of ['/files', '/files/bin', '/staff', '/s/abcdefghijklmnopqrstuvwx']) {
const r = await t.get(p);
const r = await t.get(p);
assert.strictEqual(r.status, 401, p);
assert.strictEqual(r.status, 401, p);
assert.ok(r.text.includes('Sign in'), `${p}: a sign-in page`);
assert.ok(r.text.includes('Sign in'), `${p}: a sign-in page`);
assert.ok(r.text.includes('/auth/login?next='), `${p}: with a way to sign in`);
assert.ok(r.text.includes('/auth/login?next='), `${p}: with a way to sign in`);
assert.ok(!r.text.includes('<progress'), `${p}: and nothing of the drive`);
assert.ok(!r.text.includes('<progress'), `${p}: and nothing of the drive`);
}
}
+ assert.strictEqual((await t.get('/files/fil_01JABCDEFGHJKMNPQRSTVWXYZ0')).status, 404);
// The home page and the safety rules are public: they are how somebody decides to sign up at all.
// The home page and the safety rules are public: they are how somebody decides to sign up at all.
assert.strictEqual((await t.get('/')).status, 200);
assert.strictEqual((await t.get('/')).status, 200);
assert.strictEqual((await t.get('/safety')).status, 200);
assert.strictEqual((await t.get('/safety')).status, 200);
@@ -47,11 +47,12 @@ const { bytes, upload, share } = require('./helpers/drive');
@@ -47,11 +47,12 @@ const { bytes, upload, share } = require('./helpers/drive');
});
});
await check('no preview for other people\'s files, for files that are not images, or for SVG', async () => {
await check('no preview for other people\'s files, for files that are not images, or for SVG', async () => {
- assert.strictEqual((await t.get(`/files/${photo.id}/preview?w=96`, { as: sam })).status, 404);
+ const secret = (await upload(t, kim, bytes(257, 'secret png'), { name: 'secret.png', contentType: 'image/png', audience: 'private' })).json().file;
+ assert.strictEqual((await t.get(`/files/${secret.id}/preview?w=96`, { as: sam })).status, 404);
assert.strictEqual((await t.get(`/files/${notes.id}/preview?w=96`, { as: kim })).status, 404);
assert.strictEqual((await t.get(`/files/${notes.id}/preview?w=96`, { as: kim })).status, 404);
assert.strictEqual((await t.get(`/files/${vector.id}/preview?w=96`, { as: kim })).status, 404);
assert.strictEqual((await t.get(`/files/${vector.id}/preview?w=96`, { as: kim })).status, 404);
- const anon = await t.get(`/files/${photo.id}/preview?w=96`);
- assert.ok([302, 303, 401].includes(anon.status), `signed out: ${anon.status}`);
+ assert.strictEqual((await t.get(`/files/${secret.id}/preview?w=96`)).status, 404);
+ assert.strictEqual((await t.get(`/files/${photo.id}/preview?w=96`, { as: sam })).status, 200);
});
});
await check('a single-file share shows its picture to whoever may open it; it is not a download', async () => {
await check('a single-file share shows its picture to whoever may open it; it is not a download', async () => {
@@ -117,10 +117,10 @@ const MB = 1024 * 1024;
@@ -117,10 +117,10 @@ const MB = 1024 * 1024;
assert.ok(stored > 6 * MB);
assert.ok(stored > 6 * MB);
});
});
- await check('an upload is private to its owner until it is shared', async () => {
+ await check('an upload chosen as Only me is private to its owner', async () => {
// Somebody with their own numbers: kim's day is full by now, which is a different rule.
// Somebody with their own numbers: kim's day is full by now, which is a different rule.
const nia = t.network.addUser('nia');
const nia = t.network.addUser('nia');
- const up = await upload(t, nia, bytes(200, 'private'), { name: 'mine.bin' });
+ const up = await upload(t, nia, bytes(200, 'private'), { name: 'mine.bin', audience: 'private' });
assert.strictEqual(up.status, 201, up.text);
assert.strictEqual(up.status, 201, up.text);
const id = up.json().file.id;
const id = up.json().file.id;
assert.strictEqual((await t.get(`/api/v1/files/${id}`, { as: sam })).status, 404);
assert.strictEqual((await t.get(`/api/v1/files/${id}`, { as: sam })).status, 404);
@@ -21,10 +21,10 @@ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
@@ -21,10 +21,10 @@ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
* Upload through the API the way the page's script does: POST /files, one PUT per part, then complete.
* Upload through the API the way the page's script does: POST /files, one PUT per part, then complete.
* Answers the final response (t.get's shape), so a caller can assert on it directly.
* Answers the final response (t.get's shape), so a caller can assert on it directly.
*/
*/
-async function upload(t, as, data, { name = 'file.bin', contentType = 'application/octet-stream', folder = null, onPart = null } = {}) {
+async function upload(t, as, data, { name = 'file.bin', contentType = 'application/octet-stream', folder = null, audience, onPart = null } = {}) {
const started = await t.get('/api/v1/files', {
const started = await t.get('/api/v1/files', {
as, headers: SAME,
as, headers: SAME,
- json: { name, size: data.length, content_type: contentType, folder_id: folder },
+ json: { name, size: data.length, content_type: contentType, folder_id: folder, audience },
});
});
if (started.status !== 201 || !started.json().upload) return started;
if (started.status !== 201 || !started.json().upload) return started;
const up = started.json().upload;
const up = started.json().upload;
@@ -57,7 +57,9 @@ const MB = 1024 * 1024;
@@ -57,7 +57,9 @@ const MB = 1024 * 1024;
assert.strictEqual(part.status, 200, part.text);
assert.strictEqual(part.status, 200, part.text);
const done_ = await t.get(`/api/v1/uploads/${up.id}/complete`, { bearer: key, json: {} });
const done_ = await t.get(`/api/v1/uploads/${up.id}/complete`, { bearer: key, json: {} });
assert.strictEqual(done_.status, 201, done_.text);
assert.strictEqual(done_.status, 201, done_.text);
+ assert.strictEqual((await t.get(`/files/${done_.json().file.id}`)).status, 404, 'a backup is private even when the interactive upload default is Everyone');
}
}
+ assert.strictEqual((await t.get('/api/v1/files', { bearer: key, json: { name: 'shared.txt', size: 10, content_type: 'text/plain', audience: 'public' } })).status, 403);
const fourth = await t.get('/api/v1/files', { bearer: key, json: { name: 'note-4.txt', size: 10, content_type: 'text/plain' } });
const fourth = await t.get('/api/v1/files', { bearer: key, json: { name: 'note-4.txt', size: 10, content_type: 'text/plain' } });
assert.strictEqual(fourth.status, 429, 'past the sync cap');
assert.strictEqual(fourth.status, 429, 'past the sync cap');
assert.strictEqual(fourth.json().code, 'file.daily_limit');
assert.strictEqual(fourth.json().code, 'file.daily_limit');