openvibers/openvibe.mediahub Commits

Add Download share defaults and audience checks (#400)

OP OpenVibers committed 2 days ago · e58e07401e · 18 files, +312 −44

Changes

ddiff --git a/migrations/0032_download_sharing.sql b/migrations/0032_download_sharing.sql
nnew file mode 100644
iindex 0000000..2a539c1
---- /dev/null
++++ b/migrations/0032_download_sharing.sql
@@@ -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;
ddiff --git a/public/js/upload.js b/public/js/upload.js
iindex 46793c2..cec6f0b 100644
---- a/public/js/upload.js
++++ b/public/js/upload.js
@@@ -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);
ddiff --git a/server/account.js b/server/account.js
iindex 7faf808..9ab57c6 100644
---- a/server/account.js
++++ b/server/account.js
@@@ -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) {
ddiff --git a/server/app.js b/server/app.js
iindex f6b7738..d0a94a7 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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),
ddiff --git a/server/drive/store.js b/server/drive/store.js
iindex 06a9ea5..40774af 100644
---- a/server/drive/store.js
++++ b/server/drive/store.js
@@@ -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);
}
ddiff --git a/server/drive/upload.js b/server/drive/upload.js
iindex c897f4c..da7f432 100644
---- a/server/drive/upload.js
++++ b/server/drive/upload.js
@@@ -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 {
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 230ff98..b13247a 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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({
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
iindex aa85346..372ea3c 100644
---- a/server/http/openapi.js
++++ b/server/http/openapi.js
@@@ -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() },
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 5b79364..c31d155 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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><ul
name: 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><ul
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);
});
-- 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);
});
ddiff --git a/server/sharing.js b/server/sharing.js
iindex f619c71..fa42516 100644
---- a/server/sharing.js
++++ b/server/sharing.js
@@@ -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 = globalT
owner_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 {};
};
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 9859d5c..0e68324 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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');
ddiff --git a/test/download-sharing.test.js b/test/download-sharing.test.js
nnew file mode 100644
iindex 0000000..0ab4c04
---- /dev/null
++++ b/test/download-sharing.test.js
@@@ -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); });
ddiff --git a/test/drive-download.test.js b/test/drive-download.test.js
iindex 40ca48f..5f0f32f 100644
---- a/test/drive-download.test.js
++++ b/test/drive-download.test.js
@@@ -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`);
}
});
ddiff --git a/test/drive-pages.test.js b/test/drive-pages.test.js
iindex acdbd63..65a930f 100644
---- a/test/drive-pages.test.js
++++ b/test/drive-pages.test.js
@@@ -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);
ddiff --git a/test/drive-previews.test.js b/test/drive-previews.test.js
iindex 4768e49..e073407 100644
---- a/test/drive-previews.test.js
++++ b/test/drive-previews.test.js
@@@ -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 () => {
ddiff --git a/test/drive-upload.test.js b/test/drive-upload.test.js
iindex d2efb07..c91e1cb 100644
---- a/test/drive-upload.test.js
++++ b/test/drive-upload.test.js
@@@ -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);
ddiff --git a/test/helpers/drive.js b/test/helpers/drive.js
iindex 581ced7..680bea9 100644
---- a/test/helpers/drive.js
++++ b/test/helpers/drive.js
@@@ -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;
ddiff --git a/test/sync-keys.test.js b/test/sync-keys.test.js
iindex c3aa17d..8402733 100644
---- a/test/sync-keys.test.js
++++ b/test/sync-keys.test.js
@@@ -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');