openvibers/openvibe.mediahub Commits
Add sharing defaults for Pics albums and Video (#401)
Changes
nnew file mode 100644@@@ -0,0 +1,14 @@++-- phase: expand++-- Per-person choices for new pictures, albums and videos. Download's table remains as published.++CREATE TABLE IF NOT EXISTS mh_content_share_defaults (++ owner text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('picture', 'album', 'video')),++ audience text NOT NULL CHECK (audience IN ('public', 'link', 'people', 'private')),++ updated_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (owner, kind)++);++ALTER TABLE pics_uploads ADD COLUMN IF NOT EXISTS audience text;++DO $$ BEGIN++ ALTER TABLE pics_uploads ADD CONSTRAINT pics_uploads_audience_allowed++ CHECK (audience IS NULL OR audience IN ('public', 'link', 'people', 'private'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;@@@ -118,7 +118,7 @@if (links.bbcode) box.appendChild(copyRow('BBCode (forums)', links.bbcode));if (links.html) box.appendChild(copyRow('HTML', links.html));r.main.appendChild(box);-- r.main.appendChild(el('p', { className: 'ovp-result-note', text: 'Anyone with the link can open it — no account needed.' }));++ r.main.appendChild(el('p', { className: 'ovp-result-note', text: body.audience === 'private' ? 'Only you can open it.' : body.audience === 'people' ? 'Only people you name in Share can open it.' : body.audience === 'link' ? 'Anyone with the link can open it.' : 'Anyone can open it.' }));if (body.delete_url) r.main.appendChild(el('div', { className: 'ovp-result-error', text: 'Keep this delete link, shown only now: ' + body.delete_url }));}@@@ -128,6 +128,8 @@xhr.open('POST', '/api/v1/images');xhr.setRequestHeader('Content-Type', file.type);xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name || 'pasted'));++ var audience = form.elements.audience && form.elements.audience.value;++ if (audience) xhr.setRequestHeader('X-Share-Audience', audience);if (keepBoth) xhr.setRequestHeader('X-Keep-Both', '1');xhr.upload.onprogress = function (e) { if (e.lengthComputable) r.bar.style.width = Math.round(e.loaded / e.total * 100) + '%'; };xhr.onload = function () {@@@ -57,7 +57,7 @@});}).catch(function () { return null; });}-- function snapshot() { return stored(state.file && state.upload ? { file: state.file, upload: state.upload, title: form.elements.title.value, description: form.elements.description.value, visibility: form.elements.visibility.value, saved: state.saved } : null); }++ function snapshot() { return stored(state.file && state.upload ? { file: state.file, upload: state.upload, title: form.elements.title.value, description: form.elements.description.value, audience: form.elements.audience.value, saved: state.saved } : null); }function choose(file) {if (state.file) { say('Remove this video before choosing another.'); return; }state.file = file; state.cancelled = false; state.saved = false; state.complete = null;@@@ -166,7 +166,7 @@event.preventDefault();if (!state.upload || !state.file) { say('Choose a video first.'); return; }button.disabled = true;-- json('PATCH', '/api/v1/videos/' + encodeURIComponent(state.upload.id), { title: form.elements.title.value, description: form.elements.description.value, visibility: form.elements.visibility.value }).then(function () {++ json('PATCH', '/api/v1/videos/' + encodeURIComponent(state.upload.id), { title: form.elements.title.value, description: form.elements.description.value, audience: form.elements.audience.value }).then(function () {state.saved = true; snapshot(); button.disabled = false; button.textContent = 'Details saved';if (state.complete) { stored(null); window.location.assign(state.complete.video.url); }else say('Details saved. Your video is still uploading.');@@@ -177,7 +177,7 @@if (state.file || !pending || !pending.file || !pending.upload) return;state.file = pending.file; state.upload = pending.upload; state.saved = !!pending.saved;form.elements.title.value = pending.title; form.elements.description.value = pending.description;-- form.elements.visibility.value = pending.visibility;++ form.elements.audience.value = pending.audience || (pending.visibility === 'unlisted' ? 'link' : pending.visibility || 'public');button.disabled = false;picker.add([pending.file]);say('Resuming your video…'); send();@@@ -29,6 +29,7 @@ const TABLES = [{ 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' },++ { table: 'mh_content_share_defaults', subject: 'owner', value: AS_REQUESTER, file: 'content-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' },@@@ -76,7 +76,7 @@ async function createApp(opts = {}) {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, 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) }, media, 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 });@@@ -205,14 +205,14 @@ function createApi(ctx) {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.');++ if (!['download', 'picture', 'album', 'video'].includes(kind)) 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)) {++ if (!['download', 'picture', 'album', 'video'].includes(kind) || !['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);@@@ -238,7 +238,7 @@ function createApi(ctx) {folders: folders.map(wireFolder),files: rows.map(wireFile),usage: await drive.usage(owner),-- note: q ? 'Search covers every folder; files in the bin are not listed.' : 'Your files only. A file is private until you make a share link for it.',++ note: q ? 'Search covers every folder; files in the bin are not listed.' : 'Your files only. Each file follows the audience chosen at upload or in Share.',});});@@@ -66,17 +66,17 @@ route('get', '/ping', {responses: { 200: json('The service answers', { type: 'object', properties: { ok: { const: true }, service: str() } }), ...errors([429]) },});--// ── Download audience default ─────────────────────────────────────────────────────────────────++// ── Audience defaults ─────────────────────────────────────────────────────────────────────────route('get', '/share-defaults/{kind}', {-- operationId: 'getShareDefault', summary: 'Audience for new Download uploads', tags: ['shares'], security: PERSON,-- parameters: [path('kind', str({ enum: ['download'] }))],++ operationId: 'getShareDefault', summary: 'Audience for new uploads or albums', tags: ['shares'], security: PERSON,++ parameters: [path('kind', str({ enum: ['download', 'picture', 'album', 'video'] }))],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'] }))],++ operationId: 'setShareDefault', summary: 'Save an audience default and optionally apply it to existing items', tags: ['shares'], security: PERSON,++ parameters: [path('kind', str({ enum: ['download', 'picture', 'album', 'video'] }))],requestBody: body({ type: 'object', required: ['audience'], properties: {audience: str({ enum: ['public', 'link', 'people', 'private'] }), apply: { type: 'boolean' },} }),@@@ -129,7 +129,7 @@ function createPicsApi(ctx) {}), async (req, res) => {if (req.principal.kind === 'anonymous' && !cfg.anonymous) return problem(req, res, 401, 'pics.sign_in_required', 'Sign in with OpenVibe to upload pictures.');if (!/^image\//i.test(String(req.get('content-type') || ''))) return problem(req, res, 415, 'pics.not_an_image', 'Send the picture as the body, with its image/* Content-Type.');-- const out = await pics.upload(whoOf(req), { buffer: Buffer.isBuffer(req.body) ? req.body : null, name: header(req, 'x-filename', 200) }, { title: header(req, 'x-title', 200), keepBoth: req.get('x-keep-both') === '1' });++ const out = await pics.upload(whoOf(req), { buffer: Buffer.isBuffer(req.body) ? req.body : null, name: header(req, 'x-filename', 200) }, { title: header(req, 'x-title', 200), audience: req.get('x-share-audience') || undefined, keepBoth: req.get('x-keep-both') === '1' });if (!out.ok) return problem(req, res, out.status, out.code, out.detail);res.set('Location', `/api/v1/images/${out.image.slug}`);// The same picture again: 200 with the one the person already has (duplicate: true), nothing stored twice.@@@ -140,6 +140,21 @@ function createPicsApi(ctx) {r.use(express.json({ limit: '64kb' }));++ r.get('/share-defaults/:kind', person, async (req, res) => {++ const kind = String(req.params.kind || '');++ if (!['picture', 'album'].includes(kind)) return problem(req, res, 404, 'share.kind', 'No such share kind.');++ return res.json({ kind, audience: await ctx.sharing.defaultAudience(whoOf(req).requester, kind) });++ });++ r.put('/share-defaults/:kind', person, async (req, res) => {++ const kind = String(req.params.kind || '');++ const audience = String((req.body || {}).audience || '');++ if (!['picture', 'album'].includes(kind) || !ctx.sharing.AUDIENCES.includes(audience)) return problem(req, res, 422, 'share.default_invalid', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');++ const owner = whoOf(req).requester;++ await ctx.sharing.setDefaultAudience(owner, kind, audience);++ const changed = req.body && req.body.apply === true ? await ctx.sharing.applyDefault(owner, kind, audience) : 0;++ return res.json({ kind, audience, changed });++ });++const commentAnswer = (res, result) => res.status(result.status).json(result.body);r.get('/images/:slug/comments', async (req, res) => {const row = await load(req, res);@@@ -223,7 +238,7 @@ function createPicsApi(ctx) {});// ── Albums ──────────────────────────────────────────────-- const albumWire = (a, images) => ({ id: a.id, slug: a.slug, title: a.title, description: a.description, url: `${cfg.url}/a/${a.slug}`, created_at: a.created_at, updated_at: a.updated_at, ...(images ? { images } : { image_count: Number(a.image_count || 0) }) });++ const albumWire = (a, images) => ({ id: a.id, slug: a.slug, title: a.title, description: a.description, audience: a.audience || 'public', url: `${cfg.url}/a/${a.slug}`, created_at: a.created_at, updated_at: a.updated_at, ...(images ? { images } : { image_count: Number(a.image_count || 0) }) });async function ownAlbum(req, res) {const slug = String(req.params.slug || '');const a = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;@@@ -246,7 +261,10 @@ function createPicsApi(ctx) {if (!a) return problem(req, res, 404, 'pics.album_not_found', 'No such album.');const owner = a.owner === whoOf(req).requester;if (!owner && ctx.sharing && await ctx.sharing.shareOf('album', a.id).catch(() => null) && !await ctx.sharing.canView(req.viewer, 'album', a.id).catch(() => false)) return problem(req, res, 404, 'pics.album_not_found', 'No such album.');-- return res.json(albumWire(a, (await store.albumImages(s, a.id, { visibleOnly: !owner })).map((x) => pics.toWire(x, { owner }))));++ const viewer = req.principal.kind === 'user' ? { kind: 'user', subject: req.principal.requester.slice(5) } : req.viewer;++ const images = await store.albumImages(s, a.id, { visibleOnly: !owner });++ const visible = owner ? images : (await Promise.all(images.map(async (image) => await ctx.sharing.canView(viewer, 'picture', image.id) ? image : null))).filter(Boolean);++ return res.json(albumWire(a, visible.map((image) => pics.toWire(image, { owner }))));});r.post('/albums/:slug/images', person, limits.budget('pics.image.write'), async (req, res) => {@@@ -4,7 +4,7 @@** A picture is public only when three things agree: its own row says it is listed and active, its audience is* Everyone, and the share row says Everyone too. The share row is the authority (openvibe-sdk/share); the listed-- * flag is the owner's opt-in. So a picture is unlisted by default, and it leaves the gallery the moment the owner++ * flag is the owner's opt-in. So a picture stays off the gallery by default, and it leaves the gallery the moment the owner* narrows it (server/sharing.js clears the flag) or a moderator hides it.*/const { html, raw } = require('../render/html');@@@ -72,14 +72,14 @@ const cards = (pics, rows) => rows.map((row) => card(pics, row));function render(pics, result) {const href = (sort, cursor = '') => `/?${new URLSearchParams({ sort, q: result.q, tag: result.tag, ...(cursor ? { cursor } : {}) })}`;return html`<section class="ovp-section sc-sec" aria-labelledby="sc-discover-pictures"><h2 id="sc-discover-pictures">${raw(headIcon('image'))}<span>Discover pictures</span></h2>--<p class="sc-lede">Pictures people chose to share with everyone. An upload stays unlisted until its owner publishes it.</p>++<p class="sc-lede">Pictures people chose to share with everyone. An upload stays out of the gallery until its owner publishes it.</p><div class="ovp-gallery-bar"><form class="ovp-gallery-search" action="/" method="get" role="search">${glyph('search', 17)}<label class="ovp-sr" for="gallery-search">Search pictures and tags</label><input id="gallery-search" name="q" value="${result.q}" type="search" maxlength="120" placeholder="Search pictures or tags">${result.sort !== 'popular' ? html`<input type="hidden" name="sort" value="${result.sort}">` : ''}<button type="submit">Search</button></form><nav class="ovp-gallery-tabs" aria-label="Gallery order">${['popular', 'new', 'rising'].map((sort) => html`<a href="${href(sort)}" ${sort === result.sort ? raw('aria-current="page"') : ''}>${sort[0].toUpperCase() + sort.slice(1)}</a>`)}</nav></div>${result.tag ? html`<p class="ovp-gallery-tagged">Tagged <b>#${result.tag}</b> <a href="${href(result.sort).replace(/&?tag=[^&]*/, '')}">Clear</a></p>` : ''}<div class="ovp-gallery" data-gallery>${cards(pics, result.rows)}</div>${!result.rows.length ? raw(`<div class="ovp-gallery-empty">${result.q || result.tag? kit.empty({ icon: 'search', title: 'No public pictures match yet', text: 'Try another word or tag, or see everything published so far.', action: { href: '/', label: 'Clear the search', icon: 'image' } })-- : kit.empty({ icon: 'image', title: 'The gallery is waiting for its first picture', text: 'Upload a picture, then choose Publish to gallery on its page. Until you do, it stays unlisted.', action: { href: '/mine', label: 'Your pictures', icon: 'image' } })}</div>`) : ''}++ : kit.empty({ icon: 'image', title: 'The gallery is waiting for its first picture', text: 'Upload a picture, then choose Publish to gallery on its page. Until you do, it stays out of the gallery.', action: { href: '/mine', label: 'Your pictures', icon: 'image' } })}</div>`) : ''}${result.next ? html`<a class="ovp-btn" data-gallery-next href="${href(result.sort, result.next)}">More pictures</a>` : ''}<p role="status" data-gallery-status></p></section>`;}module.exports = { PUBLIC, isPublic, tags, parseTags, options, browse, counts, publish, wire, cards, render };@@@ -216,7 +216,9 @@ function createPicsRoutes(ctx) {};const grid = (rows, empty) => (rows.length ? html`<div class="ovp-grid">${rows.map(thumb)}</div>` : html`<p class="muted">${empty}</p>`);-- function uploadForm(req, { compact = false } = {}) {++ const audienceOptions = (current) => [['public', 'Everyone'], ['link', 'Anyone with the link'], ['people', 'Specific people'], ['private', 'Only me']]++ .map(([value, label]) => html`<option value="${value}"${value === current ? raw(' selected') : ''}>${label}</option>`);++ function uploadForm(req, { compact = false, defaultAudience = 'public' } = {}) {const max = signedIn(req) ? cfg.maxImageBytes : cfg.anonymousMaxImageBytes;return html`<form class="ovp-drop${compact ? ' ovp-drop--compact' : ''}" method="post" action="/upload" enctype="multipart/form-data" data-pics-upload data-max="${max}"><div class="ovp-drop-inner">@@@ -225,6 +227,7 @@ function createPicsRoutes(ctx) {<p class="ovp-drop-hint">JPEG, PNG, GIF, WebP or AVIF, up to ${formatBytes(max)} each. Location and camera details are removed.</p><label class="ovp-btn ovp-btn--primary ovp-file" data-ov-guide="pics.upload"><input type="file" name="file" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" required data-pics-input> Choose a picture</label><div class="ovp-drop-fields"><label for="up-title">Title (optional)</label><input id="up-title" name="title" type="text" maxlength="120" placeholder="What is it?"></div>++${signedIn(req) ? html`<label>Who can open it <select name="audience">${audienceOptions(defaultAudience)}</select></label>` : ''}<button class="ovp-btn ovp-btn--primary ovp-nojs-submit" type="submit">Upload</button></div><ol class="ovp-results" data-pics-results aria-live="polite"></ol>@@@ -239,7 +242,7 @@ function createPicsRoutes(ctx) {const sizeText = (b) => formatBytes(b).replace(/\.0 /, ' ');const homeFaq = () => [{ q: 'Is OpenVibe.Pics free?', a: `Yes. A free OpenVibe account can upload ${cfg.imagesPerDay} pictures a day, each up to ${sizeText(cfg.maxImageBytes)}, and keep up to ${sizeText(cfg.quotaBytes)}.` },-- { q: 'Who can see my pictures?', a: 'A picture is unlisted to begin with: anyone with the link can open it, and nobody else. Publish it to the gallery and it is visible to everyone, with public counts and in search engines. You can narrow a picture to specific people or to yourself, and remove it at any time.' },++ { q: 'Who can see my pictures?', a: 'You choose who can open a picture when you upload it. Everyone is the default; you can choose anyone with the link, specific people, or only you. The public gallery is a separate opt-in on the picture page.' },{ q: 'What is removed from my pictures?', a: 'Everything hidden in the file: the GPS position, the camera and the rest of its metadata. Your picture is decoded and saved again without any of it before anyone sees it, and the file you uploaded is not kept.' },{ q: 'Which files can I upload?', a: `${FORMAT_NAMES}, animated GIF and WebP included. SVG and other formats are refused, so nothing is ever served as it was uploaded.` },{ q: 'Can I get a different size or format?', a: `Yes. Every picture has links at ${variants.LADDER.join(', ')} pixels wide as WebP, AVIF, JPEG or PNG, made when first asked for.` },@@@ -249,6 +252,7 @@ function createPicsRoutes(ctx) {const discovery = await gallery.browse(s, req.query);const mine = signedIn(req) ? (await store.imagesOf(s, whoOf(req).requester, { limit: 12 })).rows : [];const canUpload = signedIn(req) || cfg.anonymous;++ const pictureDefault = signedIn(req) ? await sharing.defaultAudience(whoOf(req).requester, 'picture') : 'link';const description = 'Upload a picture and get a link to share or embed anywhere: a gallery of what people chose to publish, albums, any size or format, and location and camera details removed from every picture.';const faq = homeFaq();// The curl sample keeps its line breaks: a backslash and a newline, so it can be copied and run as it is.@@@ -277,7 +281,7 @@ function createPicsRoutes(ctx) {lede: 'Drop a picture, get a link to share or embed anywhere: on a forum, in a chat, on your site. Browse what people chose to publish, or keep yours unlisted. Location and camera details are removed from every file.',visual: false,}))}--${canUpload ? uploadForm(req, { compact: true }) : html`<section class="ovp-drop ovp-drop--compact ovp-drop--signin"><div class="ovp-drop-inner"><p class="ovp-drop-title">Sign in to upload</p><p class="ovp-drop-hint">Your OpenVibe account keeps your pictures and albums together, so you can delete them whenever you like.</p><p>${signInLink('/')}</p></div></section>`}++${canUpload ? uploadForm(req, { compact: true, defaultAudience: pictureDefault }) : html`<section class="ovp-drop ovp-drop--compact ovp-drop--signin"><div class="ovp-drop-inner"><p class="ovp-drop-title">Sign in to upload</p><p class="ovp-drop-hint">Your OpenVibe account keeps your pictures and albums together, so you can delete them whenever you like.</p><p>${signInLink('/')}</p></div></section>`}${raw(gallery.render(pics, discovery))}${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><div class="ovp-section-head"><h2 id="h-latest">Your latest pictures</h2><a href="/mine">All of them</a></div>${grid(mine, 'Nothing yet: your uploads show up here.')}</section>` : ''}<p class="ovp-note">The hidden data is removed by us: your picture arrives with its GPS position, its camera and the time it was taken, and we take all of it out before anyone sees the picture. <a href="/exif">How that works</a>.</p>@@@ -286,7 +290,7 @@ ${raw(showcase.steps({items: [{ title: 'Drop a picture', text: `Choose, drag or paste ${FORMAT_NAMES}, up to ${sizeText(cfg.maxImageBytes)}. Several at once works too.` },{ title: 'Get your links', text: 'A page to share, a direct link, and Markdown, BBCode and HTML ready to copy.' },-- { title: 'Publish when you want', text: 'A picture stays unlisted until you publish it to the gallery. Then it is findable, countable and indexable.' },++ { title: 'Publish when you want', text: 'A picture stays out of the gallery until you publish it there. Then it is findable, countable and indexable.' },{ title: 'Use it anywhere', text: 'Post it in a forum or a chat, embed it on your site, or put it in an album. Delete it whenever you like.' },],}))}@@@ -294,7 +298,7 @@ ${raw(showcase.features({title: 'What you get',items: [{ icon: 'link', title: 'Links that work everywhere', text: 'A page to share, a direct link for an img tag, and Markdown, BBCode and HTML ready to copy.' },-- { icon: 'shield', title: 'Unlisted by default', text: 'Only people with the link see a picture until you publish it. Nothing is listed, ranked or indexed without you.' },++ { icon: 'shield', title: 'Your audience choice', text: 'Choose Everyone, anyone with the link, specific people, or only you. Nothing is listed, ranked or indexed without you.' },{ icon: 'spark', title: 'Clean files', text: 'Every picture is re-encoded, and its GPS position, camera serial and other hidden details are removed before anyone sees it.' },{ icon: 'folder', title: 'Albums', text: 'Put pictures together and share the album with one link.' },{ icon: 'sliders', title: 'Any size, any format', text: `Every picture also comes at ${variants.LADDER[0]} to ${variants.LADDER[variants.LADDER.length - 1]} pixels wide, as WebP, AVIF, JPEG or PNG, each with a link of its own.` },@@@ -341,9 +345,9 @@ ${raw(showcase.network({// ── Upload (the no-JavaScript form; the script uses the API) ──const spoolDir = () => config.tmpDir || path.join(os.tmpdir(), 'mediahub-uploads');-- const uploadFailed = (req, res, detail, status = 422) => page(req, res, {++ const uploadFailed = async (req, res, detail, status = 422) => page(req, res, {title: 'Upload failed', scripts: ['js/pics.js'],-- body: html`<h1>That upload did not work</h1>${notice(detail, 'warn')}${uploadForm(req, { compact: true })}`,++ body: html`<h1>That upload did not work</h1>${notice(detail, 'warn')}${uploadForm(req, { compact: true, defaultAudience: signedIn(req) ? await sharing.defaultAudience(whoOf(req).requester, 'picture') : 'link' })}`,}, status);r.post('/upload', budgeted('pics.image.upload'), async (req, res) => {@@@ -359,7 +363,7 @@ ${raw(showcase.network({try {if (got.error === 'too_large') return uploadFailed(req, res, `A picture can be up to ${formatBytes(max - 64 * 1024)}.`, 413);if (got.error || !got.file || !got.file.size) return uploadFailed(req, res, 'No picture arrived. Choose one and send the form again.');-- const out = await pics.upload(whoOf(req), { buffer: fs.readFileSync(got.file.path), name: got.file.filename }, { title: got.fields.title || '' });++ const out = await pics.upload(whoOf(req), { buffer: fs.readFileSync(got.file.path), name: got.file.filename }, { title: got.fields.title || '', audience: got.fields.audience || undefined });if (!out.ok) return uploadFailed(req, res, out.detail, out.status);if (out.deleteKey) return picturePage(req, res, out.image, { deleteKey: out.deleteKey, fresh: true });if (out.duplicate) return res.redirect(303, `/i/${out.image.slug}?again=1`);@@@ -575,21 +579,36 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" nameconst owner = whoOf(req).requester;const out = await store.imagesOf(s, owner, { limit: 48, before });const use = await store.usageOf(s, owner);++ const pictureDefault = await sharing.defaultAudience(owner, 'picture');return page(req, res, {title: 'Your pictures', scripts: ['js/pics.js'],body: html`<div class="ovp-section-head"><h1>Your pictures</h1><span class="muted">${use.images} picture${use.images === 1 ? '' : 's'} · ${formatBytes(use.bytes)} of ${formatBytes(cfg.quotaBytes)}</span></div>--${uploadForm(req, { compact: true })}++${uploadForm(req, { compact: true, defaultAudience: pictureDefault })}++<form class="ovp-form" method="post" action="/mine/share-default"><label>New pictures <select name="audience">${audienceOptions(pictureDefault)}</select></label><label><input type="checkbox" name="apply" value="1"> Apply to all my existing pictures</label><button class="ovp-btn" type="submit">Save default</button></form>${grid(out.rows, 'Nothing yet. Upload a picture above.')}${out.next ? html`<p><a class="ovp-btn" href="/mine?before=${out.next}">Older pictures</a></p>` : ''}`,});});++ r.post('/mine/share-default', mustSignIn('Sign in to change picture sharing.'), async (req, res) => {++ if (!sameSite(req, res, '/mine')) return;++ const audience = String(req.body.audience || '');++ if (!sharing.AUDIENCES.includes(audience)) return page(req, res, { title: 'Invalid audience', body: html`<h1>Choose a sharing audience</h1><p><a href="/mine">Back to your pictures</a></p>` }, 422);++ const owner = whoOf(req).requester;++ await sharing.setDefaultAudience(owner, 'picture', audience);++ if (req.body.apply === '1') await sharing.applyDefault(owner, 'picture', audience);++ return res.redirect(303, '/mine');++ });++r.get('/albums', mustSignIn('Albums belong to your OpenVibe account.'), async (req, res) => {-- const albums = await store.albumsOf(s, whoOf(req).requester);++ const owner = whoOf(req).requester;++ const albums = await store.albumsOf(s, owner);++ const albumDefault = await sharing.defaultAudience(owner, 'album');return page(req, res, {title: 'Your albums',body: html`<h1>Your albums</h1>--<form class="ovp-form ovp-inline" method="post" action="/albums"><label for="a-title">New album</label><input id="a-title" name="title" type="text" maxlength="120" required placeholder="Trip to the coast"><button class="ovp-btn ovp-btn--primary" type="submit">Create</button></form>++<form class="ovp-form ovp-inline" method="post" action="/albums"><label for="a-title">New album</label><input id="a-title" name="title" type="text" maxlength="120" required placeholder="Trip to the coast"><label>Who can open it <select name="audience">${audienceOptions(albumDefault)}</select></label><button class="ovp-btn ovp-btn--primary" type="submit">Create</button></form>++<form class="ovp-form" method="post" action="/albums/share-default"><label>New albums <select name="audience">${audienceOptions(albumDefault)}</select></label><label><input type="checkbox" name="apply" value="1"> Apply to all my existing albums</label><button class="ovp-btn" type="submit">Save default</button></form>${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class="ovp-album" href="/a/${a.slug}">${a.cover_slug ? html`<img src="/i/${a.cover_slug}/thumb.webp" alt="" loading="lazy">` : html`<span class="ovp-album-empty" aria-hidden="true"></span>`}<span class="ovp-album-title">${a.title}</span><span class="muted">${a.image_count} picture${Number(a.image_count) === 1 ? '' : 's'}</span></a>`)}</div>`: html`<p class="muted">No albums yet. Create one here, or add a picture to a new album from its page.</p>`}`,});@@@ -597,11 +616,21 @@ ${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a classr.post('/albums', mustSignIn('Sign in to use albums.'), budgeted('pics.album.create'), async (req, res) => {if (!sameSite(req, res, '/albums')) return undefined;-- const made = await pics.createAlbum(whoOf(req).requester, { title: req.body.title });++ const made = await pics.createAlbum(whoOf(req).requester, { title: req.body.title, audience: req.body.audience || undefined });if (!made.ok) return page(req, res, { title: 'Album', body: html`<h1>Your albums</h1>${notice(made.detail, 'warn')}<p><a href="/albums">Back</a></p>` }, made.status);return res.redirect(303, `/a/${made.album.slug}`);});++ r.post('/albums/share-default', mustSignIn('Sign in to change album sharing.'), async (req, res) => {++ if (!sameSite(req, res, '/albums')) return;++ const audience = String(req.body.audience || '');++ if (!sharing.AUDIENCES.includes(audience)) return page(req, res, { title: 'Invalid audience', body: html`<h1>Choose a sharing audience</h1><p><a href="/albums">Back to your albums</a></p>` }, 422);++ const owner = whoOf(req).requester;++ await sharing.setDefaultAudience(owner, 'album', audience);++ if (req.body.apply === '1') await sharing.applyDefault(owner, 'album', audience);++ return res.redirect(303, '/albums');++ });++async function ownAlbum(req, res) {const slug = String(req.params.slug || '');const album = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;@@@ -618,7 +647,8 @@ ${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a classconst share = await sharing.shareOf('album', album.id).catch(() => null);if (share && !await sharing.canView(req.viewer, 'album', album.id).catch(() => false)) return notFound(req, res);}-- const rows = (await store.albumImages(s, album.id, { visibleOnly: !owner })).filter((i) => owner || i.audience === 'public' || i.audience === 'link');++ const images = await store.albumImages(s, album.id, { visibleOnly: !owner });++ const rows = owner ? images : (await Promise.all(images.map(async (image) => await sharing.canView(req.viewer, 'picture', image.id) ? image : null))).filter(Boolean);return page(req, res, {title: album.title, noindex: true, scripts: ['js/pics.js'],image: rows[0] ? `${base}/i/${rows[0].slug}.${rows[0].ext}` : undefined,@@@ -723,7 +753,7 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="resmarkdown: true,base,summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, any size or format.',-- details: 'Uploads start unlisted: anyone with the link can open a picture. Only explicitly published pictures appear in the public gallery. Each one is re-encoded with its metadata removed, and can be had resized and converted at /i/<slug>/w/<320|640|1280|1920|2560>.<webp|avif|jpg|png>. Alt text: the uploader writes it or asks OpenVibe.AI for a draft (POST /api/v1/images/<slug>/describe). API: POST /api/v1/images with the picture as the body (an OpenVibe sign-in token as a Bearer).',++ details: 'Uploads use the person\'s audience choice, Everyone by default. Only explicitly published pictures appear in the public gallery. Each one is re-encoded with its metadata removed, and can be had resized and converted at /i/<slug>/w/<320|640|1280|1920|2560>.<webp|avif|jpg|png>. Alt text: the uploader writes it or asks OpenVibe.AI for a draft (POST /api/v1/images/<slug>/describe). API: POST /api/v1/images with the picture as the body (an OpenVibe sign-in token as a Bearer).',sections: [{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }] },],@@@ -10,14 +10,15 @@ function resumable(ctx, { person, problem, whoOf }) {r.post('/', limits.budget('pics.image.upload'), express.json({ limit: '8kb' }), async (req, res) => {const b = req.body || {};if (!Number.isInteger(b.size) || b.size < 1 || b.size > config.pics.maxImageBytes || !/^image\/(jpeg|png|webp|gif|avif)$/.test(b.content_type)) return problem(req, res, 422, 'pics.upload_invalid', 'Choose a supported image within the upload limit.');++ if (b.audience !== undefined && !ctx.sharing.AUDIENCES.includes(b.audience)) return problem(req, res, 422, 'pics.audience', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');const result = await s.tx(async () => {// A per-owner advisory lock bounds simultaneous unfinished uploads too.await s.db.query('SELECT pg_advisory_xact_lock(hashtext($1))', [whoOf(req).requester]);await s.db.query('DELETE FROM pics_uploads WHERE expires_at < $1', [s.iso()]);const count = Number(await s.db.value('SELECT count(*) FROM pics_uploads WHERE owner = $1 AND image_id IS NULL', [whoOf(req).requester]));if (count >= 4) return null;-- return s.db.maybe(`INSERT INTO pics_uploads (id, owner, size, content_type, name, title, expires_at)-- VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, ['pup_' + crypto.randomBytes(18).toString('hex'), whoOf(req).requester, b.size, b.content_type, String(b.name || 'picture').slice(0, 200), String(b.title || '').slice(0, 120), new Date(s.now() + 86400000).toISOString()]);++ return s.db.maybe(`INSERT INTO pics_uploads (id, owner, size, content_type, name, title, audience, expires_at)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, ['pup_' + crypto.randomBytes(18).toString('hex'), whoOf(req).requester, b.size, b.content_type, String(b.name || 'picture').slice(0, 200), String(b.title || '').slice(0, 120), b.audience || null, new Date(s.now() + 86400000).toISOString()]);});if (!result) return problem(req, res, 429, 'pics.upload_limit', 'Finish or cancel an upload before starting another.');return res.status(201).json(wire(result));@@@ -42,7 +43,7 @@ function resumable(ctx, { person, problem, whoOf }) {if (u.image_id) { const image = await images.imageById(s, u.image_id); return res.json(pics.toWire(image, { owner: true })); }if (u.offset_bytes !== u.size) return problem(req, res, 409, 'pics.upload_incomplete', 'Upload all bytes before completing.');const chunks = await s.db.many('SELECT bytes FROM pics_upload_chunks WHERE upload_id = $1 ORDER BY offset_bytes', [u.id]);-- const out = await pics.upload(whoOf(req), { buffer: Buffer.concat(chunks.map((x) => Buffer.from(x.bytes))), name: u.name }, { title: u.title });++ const out = await pics.upload(whoOf(req), { buffer: Buffer.concat(chunks.map((x) => Buffer.from(x.bytes))), name: u.name }, { title: u.title, audience: u.audience || undefined });if (!out.ok) return problem(req, res, out.status, out.code, out.detail);await s.db.query('UPDATE pics_uploads SET image_id = $2 WHERE id = $1', [u.id, out.image.id]);await s.db.query('DELETE FROM pics_upload_chunks WHERE upload_id = $1', [u.id]);@@@ -35,6 +35,7 @@ const REASON_TEXT = {private_info: 'Someone\'s private information', spam: 'Spam or a scam', other: 'Something else',};const LIMITS = { title: 120, description: 2000, alt: 160, albumTitle: 120, note: 500 };++const AUDIENCES = ['public', 'link', 'people', 'private'];// A difference hash this close (of 64 bits) to a blocked one is the same picture, resized, re-compressed or retouched:// measured 2026-10-15, such copies of real photos land 0-10 bits away and different photos 23-42.const DHASH_DISTANCE = 10;@@@ -88,9 +89,11 @@ function createPics({ s, media, config, log = console, events = null, sharing =* Upload one picture. who: { requester } (a person) or { anonymous: true, ip }. file: { buffer, name }.* → { ok, image, deleteKey? } or { ok: false, status, code, detail }.*/-- async function upload(who, file, { title = '', description = '', keepBoth = false } = {}) {++ async function upload(who, file, { title = '', description = '', keepBoth = false, audience } = {}) {const anonymous = !who || !who.requester;if (anonymous && !cfg.anonymous) return { ok: false, status: 401, code: 'pics.sign_in_required', detail: 'Sign in with OpenVibe to upload pictures.' };++ if (audience !== undefined && !AUDIENCES.includes(audience)) return { ok: false, status: 422, code: 'pics.audience', detail: 'Choose Everyone, Anyone with the link, Specific people, or Only me.' };++ if (anonymous && ['people', 'private'].includes(audience)) return { ok: false, status: 401, code: 'pics.sign_in_required', detail: 'Sign in to make a picture private.' };const buf = file && file.buffer;const max = anonymous ? cfg.anonymousMaxImageBytes : cfg.maxImageBytes;if (!buf || !buf.length) return { ok: false, status: 422, code: 'pics.empty', detail: 'Choose a picture to upload.' };@@@ -126,7 +129,7 @@ function createPics({ s, media, config, log = console, events = null, sharing =const thumb = await putObject(prepared.thumb.buffer, { mime: 'image/webp', name: `${id}-thumb.webp`, owner: who && who.requester, anonymous });const deleteKey = anonymous ? crypto.randomBytes(18).toString('base64url') : null;// The row and its mediahub.picture.uploaded event (signed-in uploads only) commit together.-- const row = await s.tx(async () => { const r = await store.insertImage(s, {++ let row = await s.tx(async () => { const r = await store.insertImage(s, {id, slug: slug(), owner: anonymous ? null : who.requester,delete_key_hash: deleteKey ? images.sha256(Buffer.from(deleteKey)) : null,title: base, description: cleanText(description, LIMITS.description),@@@ -137,9 +140,13 @@ function createPics({ s, media, config, log = console, events = null, sharing =});if (events && !anonymous) await events.pictureUploaded(r);return r; });-- // Unlisted by default: a picture is reachable by its link and by nobody else, until its owner publishes it-- // to the gallery. The share row is the authority, so the page, the file and the thumbnail all agree.-- if (sharing) await sharing.register(row.owner, 'picture', row.id, 'unlisted');++ // The person's saved choice is the share row authority; anonymous pictures keep link access.++ const chosen = audience || (anonymous ? 'link' : sharing ? await sharing.defaultAudience(row.owner, 'picture') : 'public');++ if (sharing) await sharing.register(row.owner, 'picture', row.id, chosen);++ if (chosen !== 'public') {++ await s.db.exec('UPDATE pics_images SET audience = $1 WHERE id = $2', [chosen, row.id]);++ row = await store.imageById(s, row.id);++ }return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}), ...(similar ? { similar: { id: similar.id, slug: similar.slug } } : {}) };}@@@ -227,18 +234,28 @@ function createPics({ s, media, config, log = console, events = null, sharing =}// ── Albums (account-only) ───────────────────────────────-- async function createAlbum(owner, { title, description = '' }) {++ async function createAlbum(owner, { title, description = '', audience } = {}) {const t = cleanLine(title, LIMITS.albumTitle);if (!t) return { ok: false, status: 422, code: 'pics.album_title', detail: 'Give the album a name.' };++ if (audience !== undefined && !AUDIENCES.includes(audience)) return { ok: false, status: 422, code: 'pics.audience', detail: 'Choose Everyone, Anyone with the link, Specific people, or Only me.' };if (await store.albumCount(s, owner) >= cfg.albumsMax) return { ok: false, status: 409, code: 'pics.album_limit', detail: `You have ${cfg.albumsMax} albums, the most you can.` };const row = await store.insertAlbum(s, { id: s.newId('alb'), slug: slug(), owner, title: t, description: cleanText(description, LIMITS.description), created_at: s.iso() });-- if (sharing) await sharing.register(owner, 'album', row.id, 'public');-- return { ok: true, album: row };++ const chosen = audience || (sharing ? await sharing.defaultAudience(owner, 'album') : 'public');++ if (sharing) await sharing.register(owner, 'album', row.id, chosen);++ if (chosen !== 'public') await s.db.exec('UPDATE pics_albums SET audience = $1 WHERE id = $2', [chosen, row.id]);++ return { ok: true, album: chosen === 'public' ? row : await store.albumBySlug(s, row.slug) };}async function addToAlbum(album, image) {if (await store.albumImageCount(s, album.id) >= cfg.albumImagesMax) return { ok: false, status: 409, code: 'pics.album_full', detail: `An album holds ${cfg.albumImagesMax} pictures.` };-- await store.addToAlbum(s, album.id, image.id);++ await s.tx(async () => {++ await store.addToAlbum(s, album.id, image.id);++ const reach = { public: 3, link: 2, people: 1, private: 0 };++ // Filing a picture in an album may narrow it, never expose it to more people.++ if (sharing && album.audience && reach[album.audience] < reach[image.audience || 'public']) {++ await sharing.setAudience({ subject: album.owner.slice(5) }, 'picture', image.id, album.audience);++ }++ });return { ok: true };}@@@ -264,7 +281,7 @@ function createPics({ s, media, config, log = console, events = null, sharing =// L1 labels: `{ labels, suggested }` (common.content-labels@1). A suggestion is shown, never applied.content_labels: contentLabels.labelsOf(row),listed: Boolean(row.listed), tags: JSON.parse(row.tags || '[]'),-- state: row.state, created_at: row.created_at, links: links(row),++ state: row.state, audience: row.audience || 'public', created_at: row.created_at, links: links(row),...(owner ? { report_count: Number(row.report_count) } : {}),});@@@ -44,7 +44,7 @@ const subjectOf = (requester) => {return null;};--function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {++function createSharing({ db, config, outbox, media, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,@@@ -103,6 +103,11 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT}} else if (ref.kind === 'video') {const vis = audience === 'public' ? 'public' : audience === 'link' ? 'unlisted' : 'private';++ const row = await tx.maybe('SELECT media_vod_id FROM video_uploads WHERE id = $1', [ref.id]);++ if (row && media) {++ const result = await media.vodUpdate(row.media_vod_id, { visibility: vis });++ if (result && result.ok === false && result.status !== 404) throw new ShareError(502, 'share.media_unavailable', 'Video storage could not change the visibility');++ }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]);@@@ -130,24 +135,38 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalTconst 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]);++ if (!KINDS.includes(kind)) throw new Error('Unknown share kind');++ const table = kind === 'download' ? 'mh_share_defaults' : 'mh_content_share_defaults';++ const row = await db.maybe(`SELECT audience FROM ${table} 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)++ if (!KINDS.includes(kind) || !AUDIENCES.includes(audience)) throw new Error('Invalid share default');++ const table = kind === 'download' ? 'mh_share_defaults' : 'mh_content_share_defaults';++ await db.exec(`INSERT INTO ${table} (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]);++ if (!KINDS.includes(kind) || !AUDIENCES.includes(audience)) throw new Error('Invalid share default');++ const queries = {++ picture: "SELECT id, audience FROM pics_images WHERE owner = $1 AND state = 'active'",++ album: 'SELECT id, audience FROM pics_albums WHERE owner = $1',++ video: "SELECT id, visibility FROM video_uploads WHERE owner = $1 AND state = 'active'",++ download: 'SELECT id FROM mh_files WHERE owner = $1 AND deleted_at IS NULL AND binned_at IS NULL',++ };++ const rows = await db.many(queries[kind], [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; }++ const current = await db.maybe('SELECT audience, owner_subject FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, kind, row.id]);++ if (current && (current.owner_subject.type !== 'user' || current.owner_subject.id !== subjectOf(owner).id)) continue;++ if (!current) {++ const oldAudience = kind === 'video' ? TO_SDK[row.visibility] || 'private' : row.audience || 'private';++ await shares.register(at(kind, row.id), { owner_subject: subjectOf(owner), audience: oldAudience });++ if (oldAudience === audience) continue;++ }if (current && current.audience !== audience) { await shares.setAudience(viewer, at(kind, row.id), audience); changed++; }++ else if (!current) { await shares.setAudience(viewer, at(kind, row.id), audience); changed++; }}return changed;}@@@ -162,7 +181,19 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT}),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)),++ canView: async (viewer, kind, id) => {++ const person = sv(viewer);++ if (await shares.canView(person, at(kind, id))) return true;++ // A named album reader can open pictures that follow that album's People audience.++ // A picture narrowed to Only me keeps its own veto, even inside a shared album.++ if (kind !== 'picture') return false;++ const picture = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, 'picture', id]);++ if (!picture || picture.audience !== 'people') return false;++ const albums = await db.many(`SELECT a.id FROM pics_album_images ai JOIN pics_albums a ON a.id = ai.album_id++ WHERE ai.image_id = $1 AND a.audience = 'people'`, [id]);++ for (const album of albums) if (await shares.canView(person, at('album', album.id))) return true;++ return false;++ },canEdit: async (viewer, kind, id) => await shares.canEdit(sv(viewer), at(kind, id)),get: async (viewer, kind, id) => await shares.get(sv(viewer), at(kind, id)),getWithRevision: async (viewer, kind, id) => await shares.getWithRevision(sv(viewer), at(kind, id)),@@@ -955,6 +955,20 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}});r.use('/api/v1/video', createVideoApiRoutes({ ...ctx, live }));++ r.get('/api/v1/share-defaults/video', ctx.principal.middleware, async (req, res) => {++ if (req.principal.kind !== 'user') return res.status(401).json({ code: 'auth.required', detail: 'Sign in to see your video default.' });++ res.set('Cache-Control', 'no-store').json({ kind: 'video', audience: await ctx.sharing.defaultAudience(req.principal.requester, 'video') });++ });++ r.put('/api/v1/share-defaults/video', ctx.principal.middleware, express.json({ limit: '8kb' }), async (req, res) => {++ if (req.principal.kind !== 'user') return res.status(401).json({ code: 'auth.required', detail: 'Sign in to change your video default.' });++ if (req.principal.viaSession && !sameOrigin(req, config.video.url)) return res.status(403).json({ code: 'request.cross_site' });++ const audience = String((req.body || {}).audience || '');++ if (!ctx.sharing.AUDIENCES.includes(audience)) return res.status(422).json({ code: 'share.default_invalid' });++ const owner = req.principal.requester;++ await ctx.sharing.setDefaultAudience(owner, 'video', audience);++ const changed = req.body.apply === true ? await ctx.sharing.applyDefault(owner, 'video', audience) : 0;++ return res.set('Cache-Control', 'no-store').json({ kind: 'video', audience, changed });++ });// Uploads' part API (server/video/upload-routes.js): raw parts and its own JSON, before the forms' parser.r.use('/api/v1/videos', createUploadApi({ ctx, config }));// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON@@@ -24,6 +24,7 @@ const cache = require('openvibe-shared/cache-policy');const { sameOrigin } = require('../http/principal');const { html, raw, notice, askAgent } = require('../render/html');const { UploadError } = require('./uploads');++const { ShareError } = require('openvibe-sdk/share');const REASON_TEXT = {child_safety: 'It sexualises or endangers a child', violence: 'Violence or a threat', harassment: 'Harassment or someone\'s private details',@@@ -31,7 +32,7 @@ const REASON_TEXT = {};function problem(res, err) {-- const e = err instanceof UploadError ? err : null;++ const e = err instanceof UploadError || err instanceof ShareError ? err : null;if (!e) throw err;return res.status(e.status).type('application/problem+json').json({ type: 'about:blank', status: e.status, code: e.code, detail: e.detail });}@@@ -61,7 +62,7 @@ function createUploadApi({ ctx, config }) {if (!guard(req, res)) return undefined;try {const b = req.body || {};-- const row = await up.start(who(req), { title: b.title, description: b.description, visibility: b.visibility, size: b.size, contentType: b.content_type, name: b.name });++ const row = await up.start(who(req), { title: b.title, description: b.description, visibility: b.visibility, audience: b.audience, size: b.size, contentType: b.content_type, name: b.name });return res.status(201).json({ upload: { id: row.id, part_size: row.part_size, parts_expected: row.parts_expected } });} catch (err) { return problem(res, err); }});@@@ -79,7 +80,7 @@ function createUploadApi({ ctx, config }) {if (!row) return undefined;try {const b = req.body || {};-- const updated = await up.update(row, { title: b.title, description: b.description, visibility: b.visibility });++ const updated = await up.update(row, { title: b.title, description: b.description, visibility: b.visibility, audience: b.audience });return res.json({ upload: { id: updated.id, title: updated.title, visibility: updated.visibility } });} catch (err) { return problem(res, err); }});@@@ -106,7 +107,7 @@ function createUploadApi({ ctx, config }) {if (!guard(req, res)) return undefined;try {const b = req.body || {};-- const row = await up.startRecording(who(req), { title: b.title, description: b.description, visibility: b.visibility, contentType: b.content_type });++ const row = await up.startRecording(who(req), { title: b.title, description: b.description, visibility: b.visibility, audience: b.audience, contentType: b.content_type });return res.status(201).json({ upload: { id: row.id } });} catch (err) { return problem(res, err); }});@@@ -166,6 +167,8 @@ function mountUploadPages(r, h) {const cfg = ctx.config.videoUploads;const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });const GB = 1024 ** 3;++ const audienceOptions = (current) => [['public', 'Everyone'], ['link', 'Anyone with the link'], ['people', 'Specific people'], ['private', 'Only me']]++ .map(([value, label]) => html`<option value="${value}"${value === current ? raw(' selected') : ''}>${label}</option>`);const statusText = (row) => (row.state === 'hidden' ? 'Hidden after reports, waiting for staff': row.status === 'uploading' ? `Upload stopped at ${Math.round((row.parts_received / row.parts_expected) * 100)}%`@@@ -246,10 +249,8 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}<div class="ovv-upload-details"><label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" required></label><label class="ovv-field"><span>Description</span><textarea name="description" rows="4" maxlength="5000"></textarea></label>--<fieldset class="ovv-visibility"><legend>Who can see it?</legend>--<label><input type="radio" name="visibility" value="public" checked><span><b>Public</b><small>Anyone can find and watch it.</small></span></label>--<label><input type="radio" name="visibility" value="unlisted"><span><b>Unlisted</b><small>Only people with the link.</small></span></label>--<label><input type="radio" name="visibility" value="private"><span><b>Only you</b><small>Your private video.</small></span></label></fieldset>++<label class="ovv-field"><span>Who can see it?</span><select name="audience">${audienceOptions(await ctx.sharing.defaultAudience(ownerOf(req), 'video'))}</select></label>++<p class="ovv-fine">For Specific people, name viewers on the video's Share sheet after upload.</p><p class="ovv-fine">Upload only what you may share. <button type="button" class="ovv-link-button" data-upload-rules>Rules and limits</button></p><p class="ovv-upload-status" role="status" aria-live="polite"></p><button class="ovv-btn ovv-btn--primary" type="submit" disabled>Save details</button>@@@ -268,7 +269,7 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}<p class="ovv-lede">Open the camera here, then review and trim your clip before saving it to Video.</p><form class="ovv-record ovv-panel" data-video-upload data-video-record><label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" placeholder="Untitled recording"></label>--<label class="ovv-field"><span>Who can see it</span><select name="visibility">${visibilityOptions('private')}</select></label>++<label class="ovv-field"><span>Who can see it</span><select name="audience">${audienceOptions(await ctx.sharing.defaultAudience(ownerOf(req), 'video'))}</select></label><input name="file" type="file" accept="video/webm,video/mp4" class="ovv-record-file" aria-label="Recorded file" tabindex="-1" required><textarea name="description" hidden></textarea><progress hidden value="0" max="1"></progress>@@@ -292,6 +293,7 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}const rows = await up.mine(ownerOf(req));await Promise.all(rows.filter((x) => x.status === 'processing').map((x) => up.refresh(x).catch(() => null)));const fresh = await up.mine(ownerOf(req));++ const defaultAudience = await ctx.sharing.defaultAudience(ownerOf(req), 'video');return page(req, res, {title: 'Your videos',crumbs: [{ label: 'Home', href: '/' }, { label: 'Your videos' }],@@@ -303,6 +305,7 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}? `${fresh.length} video${fresh.length === 1 ? '' : 's'}, newest first. A processing video finishes in the background, so you can close the page.`: 'Everything you upload to OpenVibe.Video appears here, with its state and who can see it.'}</p><a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a></div>++<form class="ovv-panel" method="post" action="/me/videos/share-default"><label>New videos <select name="audience">${audienceOptions(defaultAudience)}</select></label> <label><input type="checkbox" name="apply" value="1"> Apply to all my existing videos</label> <button class="ovv-btn" type="submit">Save default</button></form>${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : html`<div class="ovv-my-empty"><i class="fa-solid fa-film" aria-hidden="true"></i><h2>No videos yet</h2><p>Choose a video and the upload starts while you fill in the title.</p><a class="ovv-btn ovv-btn--primary" href="/upload">Upload your first video</a></div>`}<dialog class="ovv-rules" data-delete-sheet aria-label="Delete this video"><h2>Delete this video?</h2>@@@ -313,6 +316,16 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht});});++ r.post('/me/videos/share-default', mustSignIn('/me/videos', 'Sign in to change video sharing.'), async (req, res) => {++ if (!sameSite(req, res, '/me/videos')) return;++ const audience = String(req.body.audience || '');++ if (!ctx.sharing.AUDIENCES.includes(audience)) return res.status(422).type('text/plain').send('Choose a sharing audience.');++ const owner = ownerOf(req);++ await ctx.sharing.setDefaultAudience(owner, 'video', audience);++ if (req.body.apply === '1') await ctx.sharing.applyDefault(owner, 'video', audience);++ return res.redirect(303, '/me/videos');++ });++// The numbered address answers 301 to the watch address, so an old link or card still lands on the video.r.get('/u/:id', async (req, res) => {let row = await visibleUpload(req, res, String(req.params.id || ''));@@@ -324,18 +337,25 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : htreturn res.redirect(301, `/watch?v=${watchId}${tail}`);});-- /** The upload a request may see (its owner, staff, a share's people, the public), or null (and 404). */-- async function visibleUpload(req, res, id) {-- const row = await up.byId(id);++ async function maySeeUpload(req, row) {const who = whoOf(req);-- if (!up.canSee(row, who)) { notFound(req, res); return null; }++ if (!row || row.state === 'removed') return false;// Specific people and Only me: the share row decides, beside the old column. A row written before the// sharing model (an import, a cut) does not exist yet: the column still decides, failing closed for private.const own = Boolean(who.requester && row.owner === who.requester);-- if (!own && !who.staff && ctx.sharing) {++ if (own || who.staff) return true;++ if (row.state !== 'active' || row.status !== 'ready') return false;++ if (ctx.sharing) {const share = await ctx.sharing.shareOf('video', row.id).catch(() => null);-- if (share && !await ctx.sharing.canView(req.viewer, 'video', row.id).catch(() => false)) { notFound(req, res); return null; }++ if (share) return await ctx.sharing.canView(req.viewer, 'video', row.id).catch(() => false);}++ return up.canSee(row, who);++ }++++ /** The upload a request may see (its owner, staff, a share's people, the public), or null (and 404). */++ async function visibleUpload(req, res, id) {++ const row = await up.byId(id);++ if (!await maySeeUpload(req, row)) { notFound(req, res); return null; }return row;}@@@ -380,6 +400,8 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : htconst who = whoOf(req);const own = Boolean(who.requester && row.owner === who.requester);const manage = own || who.staff;++ const share = manage && ctx.sharing ? await ctx.sharing.shareOf('video', row.id).catch(() => null) : null;++ const currentAudience = share ? share.audience : row.visibility === 'unlisted' ? 'link' : row.visibility;const ready = row.status === 'ready';const vod = ready ? await up.playback(row) : null;const ai = ready ? await ctx.videoAi.forUpload(row) : null;@@@ -445,7 +467,7 @@ ${what}${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}</div>--<div class="ovv-byline-actions">${saveForm}${ready && vod && fileOf(vod) ? html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(fileOf(vod))}&source_url=${encodeURIComponent(`${String(ctx.config.video.url).replace(/\/+$/, '')}${watchPath}`)}&title=${encodeURIComponent(String(row.title || 'Watch party'))}"><i class="fa-solid fa-users" aria-hidden="true"></i> Watch together</a>` : ''}${own && ready && row.state === 'active' && studioVideoEdit ? html`<a class="ovk-btn ovk-btn--quiet" href="${studioEditUrl(row)}">Edit in Studio</a>` : ''}${raw(shareButton({ cls: 'ovv-btn', url: `${ctx.config.video.url}${watchPath}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null }))}${raw(askAgent({ text: `About the video “${row.title}” on OpenVibe.Video (${ctx.config.video.url}${watchPath}): ` }))}</div>++<div class="ovv-byline-actions">${saveForm}${ready && vod && fileOf(vod) ? html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(fileOf(vod))}&source_url=${encodeURIComponent(`${String(ctx.config.video.url).replace(/\/+$/, '')}${watchPath}`)}&title=${encodeURIComponent(String(row.title || 'Watch party'))}"><i class="fa-solid fa-users" aria-hidden="true"></i> Watch together</a>` : ''}${own && ready && row.state === 'active' && studioVideoEdit ? html`<a class="ovk-btn ovk-btn--quiet" href="${studioEditUrl(row)}">Edit in Studio</a>` : ''}${raw(shareButton({ cls: 'ovv-btn', url: `${ctx.config.video.url}${watchPath}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null, kind: 'video', id: row.id }))}${raw(askAgent({ text: `About the video “${row.title}” on OpenVibe.Video (${ctx.config.video.url}${watchPath}): ` }))}</div></div>${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>@@@ -459,7 +481,8 @@ ${manage ? html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><<form class="ovv-owner-form" method="post" action="/u/${row.id}/manage"><label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" value="${row.title}" required></label><label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="5000">${row.description}</textarea></label>--<label class="ovv-field"><span>Visibility</span><select name="visibility">${visibilityOptions(row.visibility)}</select></label>++${own ? html`<label class="ovv-field"><span>Who can see it</span><select name="audience">${audienceOptions(currentAudience)}</select></label>`++ : html`<label class="ovv-field"><span>Visibility</span><select name="visibility">${visibilityOptions(row.visibility)}</select></label>`}<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button></form><details class="ovv-danger"><summary>Delete this video</summary><form class="ovv-owner-delete" method="post" action="/u/${row.id}/delete"><label class="ovv-confirm"><input type="checkbox" name="confirm" value="1" required> I want to delete it for good</label> <button class="ovv-btn ovv-btn--danger" type="submit">Delete</button></form>@@@ -480,7 +503,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi// The AI's transcript and chapters as WebVTT tracks, for whoever may see the video.async function aiOf(req) {const row = await up.byId(req.params.id);-- if (!up.canSee(row, whoOf(req)) || row.status !== 'ready') return null;++ if (!await maySeeUpload(req, row) || row.status !== 'ready') return null;const ai = await ctx.videoAi.forUpload(row);return ai && ai.status === 'done' ? { row, ai } : null;}@@@ -500,9 +523,9 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thiif (!sameSite(req, res, `/u/${row.id}`)) return undefined;try {const b = req.body || {};-- await up.update(row, { title: b.title === undefined ? undefined : b.title, description: b.description, visibility: b.visibility === undefined ? undefined : b.visibility });++ await up.update(row, { title: b.title === undefined ? undefined : b.title, description: b.description, visibility: b.visibility === undefined ? undefined : b.visibility, audience: b.audience === undefined ? undefined : b.audience });} catch (err) {-- if (!(err instanceof UploadError)) throw err;++ if (!(err instanceof UploadError || err instanceof ShareError)) throw err;return page(req, res, { title: 'Not saved', body: html`<h1>Not saved</h1><p>${err.detail}</p><p><a href="${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}">Back</a></p>` }, err.status);}if (ctx.videoSearch) ctx.videoSearch.kick();@@@ -536,7 +559,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thir.post('/u/:id/report', async (req, res) => {const row = await up.byId(req.params.id);-- if (!up.canSee(row, whoOf(req))) return notFound(req, res);++ if (!await maySeeUpload(req, row)) return notFound(req, res);if (!sameSite(req, res, `/u/${row.id}`)) return undefined;try {const b = req.body || {};@@@ -27,6 +27,9 @@ const crypto = require('crypto');const KINDS = { 'video/mp4': 'video/mp4', 'video/quicktime': 'video/mp4', 'video/x-m4v': 'video/mp4', 'video/webm': 'video/webm', 'video/x-matroska': 'video/x-matroska' };const EXT_KINDS = { mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska' };const VISIBILITY = ['public', 'unlisted', 'private'];++const AUDIENCE = ['public', 'link', 'people', 'private'];++const visibilityOf = (audience) => audience === 'public' ? 'public' : audience === 'link' ? 'unlisted' : 'private';++const audienceOf = (visibility) => visibility === 'public' ? 'public' : visibility === 'unlisted' ? 'link' : 'private';const REASONS = ['child_safety', 'violence', 'harassment', 'sexual', 'copyright', 'spam', 'other'];const PART_SIZE = 8 * 1024 * 1024;const HIDE_AFTER = 3;@@@ -106,7 +109,9 @@ function createUploads({ s, media, config, log = console, events = null, sharingconst title = cleanLine(input.title, 120);if (!title) throw new UploadError(422, 'upload.title_required', 'Give the video a title.');const description = cleanText(input.description, 5000);-- const visibility = VISIBILITY.includes(input.visibility) ? input.visibility : 'public';++ if (input.audience !== undefined && !AUDIENCE.includes(input.audience)) throw new UploadError(422, 'upload.audience', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');++ const audience = input.audience || (VISIBILITY.includes(input.visibility) ? audienceOf(input.visibility) : sharing ? await sharing.defaultAudience(who.requester, 'video') : 'public');++ const visibility = visibilityOf(audience);const size = Number(input.size);const kind = kindOf(input.contentType, input.name);if (!kind) throw new UploadError(415, 'upload.type', 'Upload an MP4, MOV, WebM or MKV video.');@@@ -125,7 +130,7 @@ function createUploads({ s, media, config, log = console, events = null, sharingawait s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at, watch_id)VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12, $13)`,[id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, size, PART_SIZE, Math.ceil(size / PART_SIZE), now, watchId]);-- if (sharing) await sharing.register(who.requester, 'video', id, visibility);++ if (sharing) await sharing.register(who.requester, 'video', id, audience);return await byId(id);}@@@ -133,7 +138,9 @@ function createUploads({ s, media, config, log = console, events = null, sharingif (!who || !who.requester) throw new UploadError(401, 'auth.required', 'Sign in to record a video.');const title = cleanLine(input.title, 120) || `Recording, ${s.iso().slice(0, 16).replace('T', ' ')} UTC`;const description = cleanText(input.description, 5000);-- const visibility = VISIBILITY.includes(input.visibility) ? input.visibility : 'private';++ if (input.audience !== undefined && !AUDIENCE.includes(input.audience)) throw new UploadError(422, 'upload.audience', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');++ const audience = input.audience || (VISIBILITY.includes(input.visibility) ? audienceOf(input.visibility) : sharing ? await sharing.defaultAudience(who.requester, 'video') : 'public');++ const visibility = visibilityOf(audience);const t = String(input.contentType || '').split(';')[0].trim().toLowerCase();const kind = t === 'video/webm' || t === 'video/mp4' ? t : null;if (!kind) throw new UploadError(415, 'upload.type', 'This browser records in a format OpenVibe.Video does not take (WebM or MP4).');@@@ -150,7 +157,7 @@ function createUploads({ s, media, config, log = console, events = null, sharingawait s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at, watch_id)VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, 0, 0, $9, $9, $10)`,[id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, now, watchId]);-- if (sharing) await sharing.register(who.requester, 'video', id, visibility);++ if (sharing) await sharing.register(who.requester, 'video', id, audience);return await byId(id);}@@@ -275,10 +282,21 @@ function createUploads({ s, media, config, log = console, events = null, sharingconst patch = {};if (input.title !== undefined) { const t = cleanLine(input.title, 120); if (!t) throw new UploadError(422, 'upload.title_required', 'Give the video a title.'); patch.title = t; }if (input.description !== undefined) patch.description = cleanText(input.description, 5000);-- if (input.visibility !== undefined) { if (!VISIBILITY.includes(input.visibility)) throw new UploadError(422, 'upload.visibility', 'Public, unlisted or private.'); patch.visibility = input.visibility; }-- if (!Object.keys(patch).length) return row;-- if (patch.title || patch.visibility) await media.vodUpdate(row.media_vod_id, { ...(patch.title ? { title: patch.title } : {}), ...(patch.visibility ? { visibility: patch.visibility } : {}) });-- return await maybePublished(await patchRow(row.id, patch));++ if (input.visibility !== undefined && !VISIBILITY.includes(input.visibility)) throw new UploadError(422, 'upload.visibility', 'Public, unlisted or private.');++ if (input.audience !== undefined && !AUDIENCE.includes(input.audience)) throw new UploadError(422, 'upload.audience', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');++ const audience = input.audience || (input.visibility !== undefined ? audienceOf(input.visibility) : null);++ if (!Object.keys(patch).length && !audience) return row;++ if (patch.title) await media.vodUpdate(row.media_vod_id, { title: patch.title });++ const registered = audience && sharing ? await sharing.shareOf('video', row.id) : null;++ if (audience && registered) await sharing.setAudience({ subject: row.owner.slice(5) }, 'video', row.id, audience);++ else if (audience) {++ // Imports and older rows can predate share.v1. Keep their visibility change working,++ // then give them a share row for future edits and reads.++ patch.visibility = visibilityOf(audience);++ await media.vodUpdate(row.media_vod_id, { visibility: patch.visibility });++ if (sharing) await sharing.register(row.owner, 'video', row.id, audience);++ }++ return await maybePublished(Object.keys(patch).length ? await patchRow(row.id, patch) : await byId(row.id));}async function remove(row) {@@@ -76,6 +76,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');++ await t.ctx.sharing.setDefaultAudience(`user:${kim.subject}`, 'picture', 'people');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');@@@ -86,8 +87,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', 'share-defaults.json', 'shares.json', 'uniform-sharing.json', 'usage.json', 'video_likes.json'], 'and the rest of the person\'s own data');++ assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['content-share-defaults.json', '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.strictEqual(part.files.find((f) => f.name === 'content-share-defaults.json').content[0].audience, 'people');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');@@@ -116,6 +118,7 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';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_content_share_defaults, 1, 'the Pics 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,133 @@++'use strict';++const assert = require('assert');++const http = require('http');++const sharp = require('sharp');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video' } });++ const owner = t.network.addUser('owner');++ const other = t.network.addUser('other');++ const reader = t.network.addUser('reader');++ const same = { 'sec-fetch-site': 'same-origin' };++ const requester = (user) => `user:${user.subject}`;++ const share = (kind, id) => t.ctx.sharing.shareOf(kind, id);++ const hostRequest = (host, path, user, { method = 'GET', json } = {}) => new Promise((resolve, reject) => {++ const url = new URL(t.base + path);++ const body = json === undefined ? null : Buffer.from(JSON.stringify(json));++ const req = http.request({ hostname: url.hostname, port: url.port, path: url.pathname + url.search,++ method, headers: { Host: host, ...(user ? { Cookie: t.signIn(user) } : {}),++ ...(body ? { 'Content-Type': 'application/json', 'Content-Length': body.length, 'Sec-Fetch-Site': 'same-origin' } : {}) } }, (res) => {++ const parts = [];++ res.on('data', (part) => parts.push(part));++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(parts).toString('utf8') }));++ });++ req.on('error', reject);++ if (body) req.write(body);++ req.end();++ });++ const hostGet = (host, path, user) => hostRequest(host, path, user);++ let sequence = 0;++ const picture = async (user, audience) => {++ const n = ++sequence;++ const buffer = await sharp({ create: { width: 16, height: 16, channels: 3, background: { r: n, g: n * 7, b: 99 } } }).png().toBuffer();++ const out = await t.ctx.pics.upload({ requester: requester(user) }, { buffer, name: `${n}.png` }, { audience });++ assert.strictEqual(out.ok, true, JSON.stringify(out));++ return out.image;++ };++ const video = async (user, audience) => await t.ctx.videoUploads.start({ requester: requester(user), username: 'owner' }, {++ title: `Video ${++sequence}`, size: 12, contentType: 'video/mp4', name: 'clip.mp4', audience,++ });++ try {++ await check('each kind saves a personal default and rejects an invalid audience', async () => {++ for (const kind of ['picture', 'album', 'video', 'download']) {++ const get = await t.get(`/api/v1/share-defaults/${kind}`, { as: owner });++ assert.strictEqual(get.status, 200, get.text);++ assert.strictEqual(get.json().audience, 'public');++ }++ assert.strictEqual((await t.get('/api/v1/share-defaults/picture', { as: other })).json().audience, 'public');++ const invalid = await t.get('/api/v1/share-defaults/picture', { as: owner, method: 'PUT', json: { audience: 'everyone-ish' }, headers: same });++ assert.strictEqual(invalid.status, 422);++ for (const [kind, audience] of [['picture', 'private'], ['album', 'people'], ['video', 'link']]) {++ const set = await t.get(`/api/v1/share-defaults/${kind}`, { as: owner, method: 'PUT', json: { audience }, headers: same });++ assert.strictEqual(set.status, 200, set.text);++ assert.strictEqual(set.json().audience, audience);++ }++ const videoHost = await hostGet('openvibe.video', '/api/v1/share-defaults/video', owner);++ assert.strictEqual(videoHost.status, 200, videoHost.text);++ assert.strictEqual(JSON.parse(videoHost.text).audience, 'link');++ const picsHost = await hostRequest('openvibe.pics', '/api/v1/share-defaults/picture', owner,++ { method: 'PUT', json: { audience: 'private' } });++ assert.strictEqual(picsHost.status, 200, picsHost.text);++ });++++ let ownPic, otherPic, ownAlbum, otherAlbum, ownVideo, otherVideo;++ await check('new items use the saved choice, with a per-item override', async () => {++ ownPic = await picture(owner);++ otherPic = await picture(other);++ assert.strictEqual((await share('picture', ownPic.id)).audience, 'private');++ assert.strictEqual((await share('picture', otherPic.id)).audience, 'public');++ const overridePic = await picture(owner, 'public');++ assert.strictEqual((await share('picture', overridePic.id)).audience, 'public');++ ownAlbum = (await t.ctx.pics.createAlbum(requester(owner), { title: 'Owned album' })).album;++ otherAlbum = (await t.ctx.pics.createAlbum(requester(other), { title: 'Other album' })).album;++ assert.strictEqual((await share('album', ownAlbum.id)).audience, 'people');++ assert.strictEqual((await share('album', otherAlbum.id)).audience, 'public');++ ownVideo = await video(owner);++ otherVideo = await video(other);++ assert.strictEqual((await share('video', ownVideo.id)).audience, 'link');++ assert.strictEqual(ownVideo.visibility, 'unlisted');++ assert.strictEqual((await share('video', otherVideo.id)).audience, 'public');++ const overrideVideo = await video(owner, 'people');++ assert.strictEqual((await share('video', overrideVideo.id)).audience, 'people');++ assert.strictEqual(overrideVideo.visibility, 'private');++ });++++ await check('apply to all changes only the person\'s own items and updates Media video visibility', async () => {++ for (const [kind, audience] of [['picture', 'link'], ['album', 'private'], ['video', 'private']]) {++ const set = await t.get(`/api/v1/share-defaults/${kind}`, { as: owner, method: 'PUT', json: { audience, apply: true }, headers: same });++ assert.strictEqual(set.status, 200, set.text);++ assert(set.json().changed >= 1);++ }++ assert.strictEqual((await share('picture', ownPic.id)).audience, 'link');++ assert.strictEqual((await share('picture', otherPic.id)).audience, 'public');++ assert.strictEqual((await share('album', ownAlbum.id)).audience, 'private');++ assert.strictEqual((await share('album', otherAlbum.id)).audience, 'public');++ assert.strictEqual((await share('video', ownVideo.id)).audience, 'private');++ assert.strictEqual((await share('video', otherVideo.id)).audience, 'public');++ assert.strictEqual((await t.ctx.videoUploads.byId(ownVideo.id)).visibility, 'private');++ assert.strictEqual(t.media.vods.get(Number(ownVideo.media_vod_id)).visibility, 'private');++ });++++ await check('Specific people can open a ready Video watch route but a stranger cannot', async () => {++ const row = await video(owner, 'people');++ await t.ctx.sharing.addMembers({ subject: owner.subject }, 'video', row.id, [{ subject: { type: 'user', id: reader.subject }, role: 'viewer' }]);++ await t.ctx.s.db.exec("UPDATE video_uploads SET status = 'ready' WHERE id = $1", [row.id]);++ const path = `/u/${row.id}`;++ const named = await hostGet('openvibe.video', path, reader);++ const stranger = await hostGet('openvibe.video', path, other);++ assert([200, 301].includes(named.status), named.text.slice(0, 300));++ assert.strictEqual(stranger.status, 404);++ if (named.status === 301) {++ const watch = await hostGet('openvibe.video', named.headers.location, reader);++ assert.strictEqual(watch.status, 200, watch.text.slice(0, 300));++ assert(watch.text.includes(`Video ${sequence}`));++ assert(watch.text.includes(`data-kind="video" data-id="${row.id}"`), 'the watch page opens the uniform Share sheet');++ assert.strictEqual((await hostGet('openvibe.video', named.headers.location, other)).status, 404);++ }++ });++++ await check('owner pages include the saved choices and apply controls', async () => {++ const pics = await hostGet('openvibe.pics', '/mine', owner);++ assert.strictEqual(pics.status, 200, pics.text.slice(0, 200));++ assert(pics.text.includes('Apply to all my existing pictures'));++ const albums = await hostGet('openvibe.pics', '/albums', owner);++ assert.strictEqual(albums.status, 200, albums.text.slice(0, 200));++ assert(albums.text.includes('Apply to all my existing albums'));++ const videos = await hostGet('openvibe.video', '/me/videos', owner);++ assert.strictEqual(videos.status, 200, videos.text.slice(0, 200));++ assert(videos.text.includes('Apply to all my existing videos'));++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { console.error(err); process.exit(1); });@@@ -1,7 +1,7 @@'use strict';/*** Sharing on MediaHub pictures and albums (design 2026-11-06, VISIBILITY-EVERYWHERE step 4;-- * openvibe-sdk/share). Unlisted by default (publishing is explicit),++ * openvibe-sdk/share). Everyone by default (gallery publishing is explicit),* Only me and Specific people narrow it; the share row decides the page, the file, the thumbnail* and an album, and the gate never shows the picture.*/@@@ -49,10 +49,10 @@ function hostClient(t) {const putShare = (kind, id, body) => share(kind, id, { method: 'PUT', as: kate, json: body });const row = async (id) => await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['mediahub', 'picture', id]);-- await check('a new picture is registered for anyone with the link, owned by its uploader', async () => {++ await check('a new picture is registered for Everyone, owned by its uploader', async () => {const img = await upload();const s = await row(img.id);-- assert.strictEqual(s.audience, 'link');++ assert.strictEqual(s.audience, 'public');assert.deepStrictEqual(s.owner_subject, { type: 'user', id: kate.subject });assert.strictEqual((await get(`/i/${img.slug}`)).status, 200);assert.strictEqual((await get(`/i/${img.slug}.png`)).status, 200);@@@ -124,6 +124,10 @@ function hostClient(t) {const hidden = await get(`/a/${album.slug}`);assert.strictEqual(hidden.status, 200);assert.ok(!hidden.text.includes(`/i/${img.slug}.png`), 'Only me is not in the album page');++ const hiddenApi = await get(`/api/v1/albums/${album.slug}`);++ assert.strictEqual(hiddenApi.status, 200);++ assert.strictEqual(hiddenApi.json().images.length, 0, 'the album API does not reveal a private picture or count');++ assert.strictEqual((await get(`/api/v1/albums/${album.slug}`, { as: kate })).json().images.length, 1);assert.strictEqual((await get(`/i/${img.slug}.png`, { as: kate })).status, 200);// The album's own audience carries its pictures.assert.strictEqual((await putShare('album', album.id, { audience: 'private' })).status, 200);@@@ -131,6 +135,32 @@ function hostClient(t) {assert.strictEqual((await t.ctx.s.db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['mediahub', 'picture', img.id])).audience, 'private');});++ await check('an album member sees its People pictures in page, API and bytes; a stranger sees no album', async () => {++ const img = await upload();++ const made = await t.ctx.pics.createAlbum(`user:${kate.subject}`, { title: 'Family', audience: 'people' });++ assert.strictEqual(made.ok, true);++ await t.ctx.pics.addToAlbum(made.album, await picsStore.imageBySlug(t.ctx.s, img.slug));++ assert.strictEqual((await row(img.id)).audience, 'people', 'new album pictures follow the album audience');++ await putShare('album', made.album.id, { audience: 'people', members: [{ subject: { type: 'user', id: mate.subject }, role: 'viewer' }] });++ assert.strictEqual((await get(`/a/${made.album.slug}`, { as: mate })).status, 200);++ assert.strictEqual((await get(`/api/v1/albums/${made.album.slug}`, { as: mate })).json().images.length, 1);++ assert.strictEqual((await get(`/i/${img.slug}.png`, { as: mate })).status, 200);++ assert.strictEqual((await get(`/a/${made.album.slug}`, { as: sam })).status, 404);++ assert.strictEqual((await get(`/api/v1/albums/${made.album.slug}`, { as: sam })).status, 404);++ assert.strictEqual((await get(`/i/${img.slug}.png`, { as: sam })).status, 404);++ });++++ await check('filing a private picture in a public album does not expose it', async () => {++ const img = await upload();++ assert.strictEqual((await putShare('picture', img.id, { audience: 'private' })).status, 200);++ const made = await t.ctx.pics.createAlbum(`user:${kate.subject}`, { title: 'Open album', audience: 'public' });++ assert.strictEqual(made.ok, true);++ await t.ctx.pics.addToAlbum(made.album, await picsStore.imageBySlug(t.ctx.s, img.slug));++ assert.strictEqual((await row(img.id)).audience, 'private');++ assert.strictEqual((await get(`/api/v1/albums/${made.album.slug}`)).json().images.length, 0);++ assert.strictEqual((await get(`/api/v1/albums/${made.album.slug}`, { as: kate })).json().images.length, 1);++ });++await done();await t.close();})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+-- phase: expand
+-- Per-person choices for new pictures, albums and videos. Download's table remains as published.
+CREATE TABLE IF NOT EXISTS mh_content_share_defaults (
+ owner text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('picture', 'album', 'video')),
+ audience text NOT NULL CHECK (audience IN ('public', 'link', 'people', 'private')),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (owner, kind)
+);
+ALTER TABLE pics_uploads ADD COLUMN IF NOT EXISTS audience text;
+DO $$ BEGIN
+ ALTER TABLE pics_uploads ADD CONSTRAINT pics_uploads_audience_allowed
+ CHECK (audience IS NULL OR audience IN ('public', 'link', 'people', 'private'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
@@ -118,7 +118,7 @@
@@ -118,7 +118,7 @@
if (links.bbcode) box.appendChild(copyRow('BBCode (forums)', links.bbcode));
if (links.bbcode) box.appendChild(copyRow('BBCode (forums)', links.bbcode));
if (links.html) box.appendChild(copyRow('HTML', links.html));
if (links.html) box.appendChild(copyRow('HTML', links.html));
r.main.appendChild(box);
r.main.appendChild(box);
- r.main.appendChild(el('p', { className: 'ovp-result-note', text: 'Anyone with the link can open it — no account needed.' }));
+ r.main.appendChild(el('p', { className: 'ovp-result-note', text: body.audience === 'private' ? 'Only you can open it.' : body.audience === 'people' ? 'Only people you name in Share can open it.' : body.audience === 'link' ? 'Anyone with the link can open it.' : 'Anyone can open it.' }));
if (body.delete_url) r.main.appendChild(el('div', { className: 'ovp-result-error', text: 'Keep this delete link, shown only now: ' + body.delete_url }));
if (body.delete_url) r.main.appendChild(el('div', { className: 'ovp-result-error', text: 'Keep this delete link, shown only now: ' + body.delete_url }));
}
}
@@ -128,6 +128,8 @@
@@ -128,6 +128,8 @@
xhr.open('POST', '/api/v1/images');
xhr.open('POST', '/api/v1/images');
xhr.setRequestHeader('Content-Type', file.type);
xhr.setRequestHeader('Content-Type', file.type);
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name || 'pasted'));
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name || 'pasted'));
+ var audience = form.elements.audience && form.elements.audience.value;
+ if (audience) xhr.setRequestHeader('X-Share-Audience', audience);
if (keepBoth) xhr.setRequestHeader('X-Keep-Both', '1');
if (keepBoth) xhr.setRequestHeader('X-Keep-Both', '1');
xhr.upload.onprogress = function (e) { if (e.lengthComputable) r.bar.style.width = Math.round(e.loaded / e.total * 100) + '%'; };
xhr.upload.onprogress = function (e) { if (e.lengthComputable) r.bar.style.width = Math.round(e.loaded / e.total * 100) + '%'; };
xhr.onload = function () {
xhr.onload = function () {
@@ -57,7 +57,7 @@
@@ -57,7 +57,7 @@
});
});
}).catch(function () { return null; });
}).catch(function () { return null; });
}
}
- function snapshot() { return stored(state.file && state.upload ? { file: state.file, upload: state.upload, title: form.elements.title.value, description: form.elements.description.value, visibility: form.elements.visibility.value, saved: state.saved } : null); }
+ function snapshot() { return stored(state.file && state.upload ? { file: state.file, upload: state.upload, title: form.elements.title.value, description: form.elements.description.value, audience: form.elements.audience.value, saved: state.saved } : null); }
function choose(file) {
function choose(file) {
if (state.file) { say('Remove this video before choosing another.'); return; }
if (state.file) { say('Remove this video before choosing another.'); return; }
state.file = file; state.cancelled = false; state.saved = false; state.complete = null;
state.file = file; state.cancelled = false; state.saved = false; state.complete = null;
@@ -166,7 +166,7 @@
@@ -166,7 +166,7 @@
event.preventDefault();
event.preventDefault();
if (!state.upload || !state.file) { say('Choose a video first.'); return; }
if (!state.upload || !state.file) { say('Choose a video first.'); return; }
button.disabled = true;
button.disabled = true;
- json('PATCH', '/api/v1/videos/' + encodeURIComponent(state.upload.id), { title: form.elements.title.value, description: form.elements.description.value, visibility: form.elements.visibility.value }).then(function () {
+ json('PATCH', '/api/v1/videos/' + encodeURIComponent(state.upload.id), { title: form.elements.title.value, description: form.elements.description.value, audience: form.elements.audience.value }).then(function () {
state.saved = true; snapshot(); button.disabled = false; button.textContent = 'Details saved';
state.saved = true; snapshot(); button.disabled = false; button.textContent = 'Details saved';
if (state.complete) { stored(null); window.location.assign(state.complete.video.url); }
if (state.complete) { stored(null); window.location.assign(state.complete.video.url); }
else say('Details saved. Your video is still uploading.');
else say('Details saved. Your video is still uploading.');
@@ -177,7 +177,7 @@
@@ -177,7 +177,7 @@
if (state.file || !pending || !pending.file || !pending.upload) return;
if (state.file || !pending || !pending.file || !pending.upload) return;
state.file = pending.file; state.upload = pending.upload; state.saved = !!pending.saved;
state.file = pending.file; state.upload = pending.upload; state.saved = !!pending.saved;
form.elements.title.value = pending.title; form.elements.description.value = pending.description;
form.elements.title.value = pending.title; form.elements.description.value = pending.description;
- form.elements.visibility.value = pending.visibility;
+ form.elements.audience.value = pending.audience || (pending.visibility === 'unlisted' ? 'link' : pending.visibility || 'public');
button.disabled = false;
button.disabled = false;
picker.add([pending.file]);
picker.add([pending.file]);
say('Resuming your video…'); send();
say('Resuming your video…'); send();
@@ -29,6 +29,7 @@ const TABLES = [
@@ -29,6 +29,7 @@ const TABLES = [
{ 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' },
{ table: 'mh_share_defaults', subject: 'owner', value: AS_REQUESTER, file: 'share-defaults.json' },
+ { table: 'mh_content_share_defaults', subject: 'owner', value: AS_REQUESTER, file: 'content-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' },
@@ -76,7 +76,7 @@ async function createApp(opts = {}) {
@@ -76,7 +76,7 @@ async function createApp(opts = {}) {
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, video and drive upload.
// 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) }, media, now: s.now, fetchImpl, log });
const uploads = createUploads({ s, media, config, service: drive, sharing, 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 });
@@ -205,14 +205,14 @@ function createApi(ctx) {
@@ -205,14 +205,14 @@ function createApi(ctx) {
r.get('/share-defaults/:kind', person, async (req, res) => {
r.get('/share-defaults/:kind', person, async (req, res) => {
const kind = String(req.params.kind || '');
const kind = String(req.params.kind || '');
- if (kind !== 'download') return problem(req, res, 404, 'share.kind', 'No such share kind.');
+ if (!['download', 'picture', 'album', 'video'].includes(kind)) return problem(req, res, 404, 'share.kind', 'No such share kind.');
return res.json({ kind, audience: await sharing.defaultAudience(personOf(req), kind) });
return res.json({ kind, audience: await sharing.defaultAudience(personOf(req), kind) });
});
});
r.put('/share-defaults/:kind', person, async (req, res) => {
r.put('/share-defaults/:kind', person, async (req, res) => {
const kind = String(req.params.kind || '');
const kind = String(req.params.kind || '');
const audience = String((req.body || {}).audience || '');
const audience = String((req.body || {}).audience || '');
- if (kind !== 'download' || !['public', 'link', 'people', 'private'].includes(audience)) {
+ if (!['download', 'picture', 'album', 'video'].includes(kind) || !['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.');
return problem(req, res, 422, 'share.default_invalid', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');
}
}
const owner = personOf(req);
const owner = personOf(req);
@@ -238,7 +238,7 @@ function createApi(ctx) {
@@ -238,7 +238,7 @@ function createApi(ctx) {
folders: folders.map(wireFolder),
folders: folders.map(wireFolder),
files: rows.map(wireFile),
files: rows.map(wireFile),
usage: await drive.usage(owner),
usage: await drive.usage(owner),
- note: q ? 'Search covers every folder; files in the bin are not listed.' : 'Your files only. A file is private until you make a share link for it.',
+ note: q ? 'Search covers every folder; files in the bin are not listed.' : 'Your files only. Each file follows the audience chosen at upload or in Share.',
});
});
});
});
@@ -66,17 +66,17 @@ route('get', '/ping', {
@@ -66,17 +66,17 @@ 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 ─────────────────────────────────────────────────────────────────
+// ── Audience defaults ─────────────────────────────────────────────────────────────────────────
route('get', '/share-defaults/{kind}', {
route('get', '/share-defaults/{kind}', {
- operationId: 'getShareDefault', summary: 'Audience for new Download uploads', tags: ['shares'], security: PERSON,
- parameters: [path('kind', str({ enum: ['download'] }))],
+ operationId: 'getShareDefault', summary: 'Audience for new uploads or albums', tags: ['shares'], security: PERSON,
+ parameters: [path('kind', str({ enum: ['download', 'picture', 'album', 'video'] }))],
responses: { 200: json('The saved default, or Everyone when none is saved', { type: 'object', properties: {
responses: { 200: json('The saved default, or Everyone when none is saved', { type: 'object', properties: {
kind: str(), audience: str({ enum: ['public', 'link', 'people', 'private'] }),
kind: str(), audience: str({ enum: ['public', 'link', 'people', 'private'] }),
} }), ...errors([401, 404, 429]) },
} }), ...errors([401, 404, 429]) },
});
});
route('put', '/share-defaults/{kind}', {
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'] }))],
+ operationId: 'setShareDefault', summary: 'Save an audience default and optionally apply it to existing items', tags: ['shares'], security: PERSON,
+ parameters: [path('kind', str({ enum: ['download', 'picture', 'album', 'video'] }))],
requestBody: body({ type: 'object', required: ['audience'], properties: {
requestBody: body({ type: 'object', required: ['audience'], properties: {
audience: str({ enum: ['public', 'link', 'people', 'private'] }), apply: { type: 'boolean' },
audience: str({ enum: ['public', 'link', 'people', 'private'] }), apply: { type: 'boolean' },
} }),
} }),
@@ -129,7 +129,7 @@ function createPicsApi(ctx) {
@@ -129,7 +129,7 @@ function createPicsApi(ctx) {
}), async (req, res) => {
}), async (req, res) => {
if (req.principal.kind === 'anonymous' && !cfg.anonymous) return problem(req, res, 401, 'pics.sign_in_required', 'Sign in with OpenVibe to upload pictures.');
if (req.principal.kind === 'anonymous' && !cfg.anonymous) return problem(req, res, 401, 'pics.sign_in_required', 'Sign in with OpenVibe to upload pictures.');
if (!/^image\//i.test(String(req.get('content-type') || ''))) return problem(req, res, 415, 'pics.not_an_image', 'Send the picture as the body, with its image/* Content-Type.');
if (!/^image\//i.test(String(req.get('content-type') || ''))) return problem(req, res, 415, 'pics.not_an_image', 'Send the picture as the body, with its image/* Content-Type.');
- const out = await pics.upload(whoOf(req), { buffer: Buffer.isBuffer(req.body) ? req.body : null, name: header(req, 'x-filename', 200) }, { title: header(req, 'x-title', 200), keepBoth: req.get('x-keep-both') === '1' });
+ const out = await pics.upload(whoOf(req), { buffer: Buffer.isBuffer(req.body) ? req.body : null, name: header(req, 'x-filename', 200) }, { title: header(req, 'x-title', 200), audience: req.get('x-share-audience') || undefined, keepBoth: req.get('x-keep-both') === '1' });
if (!out.ok) return problem(req, res, out.status, out.code, out.detail);
if (!out.ok) return problem(req, res, out.status, out.code, out.detail);
res.set('Location', `/api/v1/images/${out.image.slug}`);
res.set('Location', `/api/v1/images/${out.image.slug}`);
// The same picture again: 200 with the one the person already has (duplicate: true), nothing stored twice.
// The same picture again: 200 with the one the person already has (duplicate: true), nothing stored twice.
@@ -140,6 +140,21 @@ function createPicsApi(ctx) {
@@ -140,6 +140,21 @@ function createPicsApi(ctx) {
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
+ r.get('/share-defaults/:kind', person, async (req, res) => {
+ const kind = String(req.params.kind || '');
+ if (!['picture', 'album'].includes(kind)) return problem(req, res, 404, 'share.kind', 'No such share kind.');
+ return res.json({ kind, audience: await ctx.sharing.defaultAudience(whoOf(req).requester, kind) });
+ });
+ r.put('/share-defaults/:kind', person, async (req, res) => {
+ const kind = String(req.params.kind || '');
+ const audience = String((req.body || {}).audience || '');
+ if (!['picture', 'album'].includes(kind) || !ctx.sharing.AUDIENCES.includes(audience)) return problem(req, res, 422, 'share.default_invalid', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');
+ const owner = whoOf(req).requester;
+ await ctx.sharing.setDefaultAudience(owner, kind, audience);
+ const changed = req.body && req.body.apply === true ? await ctx.sharing.applyDefault(owner, kind, audience) : 0;
+ return res.json({ kind, audience, changed });
+ });
+
const commentAnswer = (res, result) => res.status(result.status).json(result.body);
const commentAnswer = (res, result) => res.status(result.status).json(result.body);
r.get('/images/:slug/comments', async (req, res) => {
r.get('/images/:slug/comments', async (req, res) => {
const row = await load(req, res);
const row = await load(req, res);
@@ -223,7 +238,7 @@ function createPicsApi(ctx) {
@@ -223,7 +238,7 @@ function createPicsApi(ctx) {
});
});
// ── Albums ──────────────────────────────────────────────
// ── Albums ──────────────────────────────────────────────
- const albumWire = (a, images) => ({ id: a.id, slug: a.slug, title: a.title, description: a.description, url: `${cfg.url}/a/${a.slug}`, created_at: a.created_at, updated_at: a.updated_at, ...(images ? { images } : { image_count: Number(a.image_count || 0) }) });
+ const albumWire = (a, images) => ({ id: a.id, slug: a.slug, title: a.title, description: a.description, audience: a.audience || 'public', url: `${cfg.url}/a/${a.slug}`, created_at: a.created_at, updated_at: a.updated_at, ...(images ? { images } : { image_count: Number(a.image_count || 0) }) });
async function ownAlbum(req, res) {
async function ownAlbum(req, res) {
const slug = String(req.params.slug || '');
const slug = String(req.params.slug || '');
const a = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;
const a = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;
@@ -246,7 +261,10 @@ function createPicsApi(ctx) {
@@ -246,7 +261,10 @@ function createPicsApi(ctx) {
if (!a) return problem(req, res, 404, 'pics.album_not_found', 'No such album.');
if (!a) return problem(req, res, 404, 'pics.album_not_found', 'No such album.');
const owner = a.owner === whoOf(req).requester;
const owner = a.owner === whoOf(req).requester;
if (!owner && ctx.sharing && await ctx.sharing.shareOf('album', a.id).catch(() => null) && !await ctx.sharing.canView(req.viewer, 'album', a.id).catch(() => false)) return problem(req, res, 404, 'pics.album_not_found', 'No such album.');
if (!owner && ctx.sharing && await ctx.sharing.shareOf('album', a.id).catch(() => null) && !await ctx.sharing.canView(req.viewer, 'album', a.id).catch(() => false)) return problem(req, res, 404, 'pics.album_not_found', 'No such album.');
- return res.json(albumWire(a, (await store.albumImages(s, a.id, { visibleOnly: !owner })).map((x) => pics.toWire(x, { owner }))));
+ const viewer = req.principal.kind === 'user' ? { kind: 'user', subject: req.principal.requester.slice(5) } : req.viewer;
+ const images = await store.albumImages(s, a.id, { visibleOnly: !owner });
+ const visible = owner ? images : (await Promise.all(images.map(async (image) => await ctx.sharing.canView(viewer, 'picture', image.id) ? image : null))).filter(Boolean);
+ return res.json(albumWire(a, visible.map((image) => pics.toWire(image, { owner }))));
});
});
r.post('/albums/:slug/images', person, limits.budget('pics.image.write'), async (req, res) => {
r.post('/albums/:slug/images', person, limits.budget('pics.image.write'), async (req, res) => {
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
*
*
* A picture is public only when three things agree: its own row says it is listed and active, its audience is
* A picture is public only when three things agree: its own row says it is listed and active, its audience is
* Everyone, and the share row says Everyone too. The share row is the authority (openvibe-sdk/share); the listed
* Everyone, and the share row says Everyone too. The share row is the authority (openvibe-sdk/share); the listed
- * flag is the owner's opt-in. So a picture is unlisted by default, and it leaves the gallery the moment the owner
+ * flag is the owner's opt-in. So a picture stays off the gallery by default, and it leaves the gallery the moment the owner
* narrows it (server/sharing.js clears the flag) or a moderator hides it.
* narrows it (server/sharing.js clears the flag) or a moderator hides it.
*/
*/
const { html, raw } = require('../render/html');
const { html, raw } = require('../render/html');
@@ -72,14 +72,14 @@ const cards = (pics, rows) => rows.map((row) => card(pics, row));
@@ -72,14 +72,14 @@ const cards = (pics, rows) => rows.map((row) => card(pics, row));
function render(pics, result) {
function render(pics, result) {
const href = (sort, cursor = '') => `/?${new URLSearchParams({ sort, q: result.q, tag: result.tag, ...(cursor ? { cursor } : {}) })}`;
const href = (sort, cursor = '') => `/?${new URLSearchParams({ sort, q: result.q, tag: result.tag, ...(cursor ? { cursor } : {}) })}`;
return html`<section class="ovp-section sc-sec" aria-labelledby="sc-discover-pictures"><h2 id="sc-discover-pictures">${raw(headIcon('image'))}<span>Discover pictures</span></h2>
return html`<section class="ovp-section sc-sec" aria-labelledby="sc-discover-pictures"><h2 id="sc-discover-pictures">${raw(headIcon('image'))}<span>Discover pictures</span></h2>
-<p class="sc-lede">Pictures people chose to share with everyone. An upload stays unlisted until its owner publishes it.</p>
+<p class="sc-lede">Pictures people chose to share with everyone. An upload stays out of the gallery until its owner publishes it.</p>
<div class="ovp-gallery-bar"><form class="ovp-gallery-search" action="/" method="get" role="search">${glyph('search', 17)}<label class="ovp-sr" for="gallery-search">Search pictures and tags</label><input id="gallery-search" name="q" value="${result.q}" type="search" maxlength="120" placeholder="Search pictures or tags">${result.sort !== 'popular' ? html`<input type="hidden" name="sort" value="${result.sort}">` : ''}<button type="submit">Search</button></form>
<div class="ovp-gallery-bar"><form class="ovp-gallery-search" action="/" method="get" role="search">${glyph('search', 17)}<label class="ovp-sr" for="gallery-search">Search pictures and tags</label><input id="gallery-search" name="q" value="${result.q}" type="search" maxlength="120" placeholder="Search pictures or tags">${result.sort !== 'popular' ? html`<input type="hidden" name="sort" value="${result.sort}">` : ''}<button type="submit">Search</button></form>
<nav class="ovp-gallery-tabs" aria-label="Gallery order">${['popular', 'new', 'rising'].map((sort) => html`<a href="${href(sort)}" ${sort === result.sort ? raw('aria-current="page"') : ''}>${sort[0].toUpperCase() + sort.slice(1)}</a>`)}</nav></div>
<nav class="ovp-gallery-tabs" aria-label="Gallery order">${['popular', 'new', 'rising'].map((sort) => html`<a href="${href(sort)}" ${sort === result.sort ? raw('aria-current="page"') : ''}>${sort[0].toUpperCase() + sort.slice(1)}</a>`)}</nav></div>
${result.tag ? html`<p class="ovp-gallery-tagged">Tagged <b>#${result.tag}</b> <a href="${href(result.sort).replace(/&?tag=[^&]*/, '')}">Clear</a></p>` : ''}
${result.tag ? html`<p class="ovp-gallery-tagged">Tagged <b>#${result.tag}</b> <a href="${href(result.sort).replace(/&?tag=[^&]*/, '')}">Clear</a></p>` : ''}
<div class="ovp-gallery" data-gallery>${cards(pics, result.rows)}</div>
<div class="ovp-gallery" data-gallery>${cards(pics, result.rows)}</div>
${!result.rows.length ? raw(`<div class="ovp-gallery-empty">${result.q || result.tag
${!result.rows.length ? raw(`<div class="ovp-gallery-empty">${result.q || result.tag
? kit.empty({ icon: 'search', title: 'No public pictures match yet', text: 'Try another word or tag, or see everything published so far.', action: { href: '/', label: 'Clear the search', icon: 'image' } })
? kit.empty({ icon: 'search', title: 'No public pictures match yet', text: 'Try another word or tag, or see everything published so far.', action: { href: '/', label: 'Clear the search', icon: 'image' } })
- : kit.empty({ icon: 'image', title: 'The gallery is waiting for its first picture', text: 'Upload a picture, then choose Publish to gallery on its page. Until you do, it stays unlisted.', action: { href: '/mine', label: 'Your pictures', icon: 'image' } })}</div>`) : ''}
+ : kit.empty({ icon: 'image', title: 'The gallery is waiting for its first picture', text: 'Upload a picture, then choose Publish to gallery on its page. Until you do, it stays out of the gallery.', action: { href: '/mine', label: 'Your pictures', icon: 'image' } })}</div>`) : ''}
${result.next ? html`<a class="ovp-btn" data-gallery-next href="${href(result.sort, result.next)}">More pictures</a>` : ''}<p role="status" data-gallery-status></p></section>`;
${result.next ? html`<a class="ovp-btn" data-gallery-next href="${href(result.sort, result.next)}">More pictures</a>` : ''}<p role="status" data-gallery-status></p></section>`;
}
}
module.exports = { PUBLIC, isPublic, tags, parseTags, options, browse, counts, publish, wire, cards, render };
module.exports = { PUBLIC, isPublic, tags, parseTags, options, browse, counts, publish, wire, cards, render };
@@ -216,7 +216,9 @@ function createPicsRoutes(ctx) {
@@ -216,7 +216,9 @@ function createPicsRoutes(ctx) {
};
};
const grid = (rows, empty) => (rows.length ? html`<div class="ovp-grid">${rows.map(thumb)}</div>` : html`<p class="muted">${empty}</p>`);
const grid = (rows, empty) => (rows.length ? html`<div class="ovp-grid">${rows.map(thumb)}</div>` : html`<p class="muted">${empty}</p>`);
- function uploadForm(req, { compact = false } = {}) {
+ const audienceOptions = (current) => [['public', 'Everyone'], ['link', 'Anyone with the link'], ['people', 'Specific people'], ['private', 'Only me']]
+ .map(([value, label]) => html`<option value="${value}"${value === current ? raw(' selected') : ''}>${label}</option>`);
+ function uploadForm(req, { compact = false, defaultAudience = 'public' } = {}) {
const max = signedIn(req) ? cfg.maxImageBytes : cfg.anonymousMaxImageBytes;
const max = signedIn(req) ? cfg.maxImageBytes : cfg.anonymousMaxImageBytes;
return html`<form class="ovp-drop${compact ? ' ovp-drop--compact' : ''}" method="post" action="/upload" enctype="multipart/form-data" data-pics-upload data-max="${max}">
return html`<form class="ovp-drop${compact ? ' ovp-drop--compact' : ''}" method="post" action="/upload" enctype="multipart/form-data" data-pics-upload data-max="${max}">
<div class="ovp-drop-inner">
<div class="ovp-drop-inner">
@@ -225,6 +227,7 @@ function createPicsRoutes(ctx) {
@@ -225,6 +227,7 @@ function createPicsRoutes(ctx) {
<p class="ovp-drop-hint">JPEG, PNG, GIF, WebP or AVIF, up to ${formatBytes(max)} each. Location and camera details are removed.</p>
<p class="ovp-drop-hint">JPEG, PNG, GIF, WebP or AVIF, up to ${formatBytes(max)} each. Location and camera details are removed.</p>
<label class="ovp-btn ovp-btn--primary ovp-file" data-ov-guide="pics.upload"><input type="file" name="file" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" required data-pics-input> Choose a picture</label>
<label class="ovp-btn ovp-btn--primary ovp-file" data-ov-guide="pics.upload"><input type="file" name="file" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" required data-pics-input> Choose a picture</label>
<div class="ovp-drop-fields"><label for="up-title">Title (optional)</label><input id="up-title" name="title" type="text" maxlength="120" placeholder="What is it?"></div>
<div class="ovp-drop-fields"><label for="up-title">Title (optional)</label><input id="up-title" name="title" type="text" maxlength="120" placeholder="What is it?"></div>
+${signedIn(req) ? html`<label>Who can open it <select name="audience">${audienceOptions(defaultAudience)}</select></label>` : ''}
<button class="ovp-btn ovp-btn--primary ovp-nojs-submit" type="submit">Upload</button>
<button class="ovp-btn ovp-btn--primary ovp-nojs-submit" type="submit">Upload</button>
</div>
</div>
<ol class="ovp-results" data-pics-results aria-live="polite"></ol>
<ol class="ovp-results" data-pics-results aria-live="polite"></ol>
@@ -239,7 +242,7 @@ function createPicsRoutes(ctx) {
@@ -239,7 +242,7 @@ function createPicsRoutes(ctx) {
const sizeText = (b) => formatBytes(b).replace(/\.0 /, ' ');
const sizeText = (b) => formatBytes(b).replace(/\.0 /, ' ');
const homeFaq = () => [
const homeFaq = () => [
{ q: 'Is OpenVibe.Pics free?', a: `Yes. A free OpenVibe account can upload ${cfg.imagesPerDay} pictures a day, each up to ${sizeText(cfg.maxImageBytes)}, and keep up to ${sizeText(cfg.quotaBytes)}.` },
{ q: 'Is OpenVibe.Pics free?', a: `Yes. A free OpenVibe account can upload ${cfg.imagesPerDay} pictures a day, each up to ${sizeText(cfg.maxImageBytes)}, and keep up to ${sizeText(cfg.quotaBytes)}.` },
- { q: 'Who can see my pictures?', a: 'A picture is unlisted to begin with: anyone with the link can open it, and nobody else. Publish it to the gallery and it is visible to everyone, with public counts and in search engines. You can narrow a picture to specific people or to yourself, and remove it at any time.' },
+ { q: 'Who can see my pictures?', a: 'You choose who can open a picture when you upload it. Everyone is the default; you can choose anyone with the link, specific people, or only you. The public gallery is a separate opt-in on the picture page.' },
{ q: 'What is removed from my pictures?', a: 'Everything hidden in the file: the GPS position, the camera and the rest of its metadata. Your picture is decoded and saved again without any of it before anyone sees it, and the file you uploaded is not kept.' },
{ q: 'What is removed from my pictures?', a: 'Everything hidden in the file: the GPS position, the camera and the rest of its metadata. Your picture is decoded and saved again without any of it before anyone sees it, and the file you uploaded is not kept.' },
{ q: 'Which files can I upload?', a: `${FORMAT_NAMES}, animated GIF and WebP included. SVG and other formats are refused, so nothing is ever served as it was uploaded.` },
{ q: 'Which files can I upload?', a: `${FORMAT_NAMES}, animated GIF and WebP included. SVG and other formats are refused, so nothing is ever served as it was uploaded.` },
{ q: 'Can I get a different size or format?', a: `Yes. Every picture has links at ${variants.LADDER.join(', ')} pixels wide as WebP, AVIF, JPEG or PNG, made when first asked for.` },
{ q: 'Can I get a different size or format?', a: `Yes. Every picture has links at ${variants.LADDER.join(', ')} pixels wide as WebP, AVIF, JPEG or PNG, made when first asked for.` },
@@ -249,6 +252,7 @@ function createPicsRoutes(ctx) {
@@ -249,6 +252,7 @@ function createPicsRoutes(ctx) {
const discovery = await gallery.browse(s, req.query);
const discovery = await gallery.browse(s, req.query);
const mine = signedIn(req) ? (await store.imagesOf(s, whoOf(req).requester, { limit: 12 })).rows : [];
const mine = signedIn(req) ? (await store.imagesOf(s, whoOf(req).requester, { limit: 12 })).rows : [];
const canUpload = signedIn(req) || cfg.anonymous;
const canUpload = signedIn(req) || cfg.anonymous;
+ const pictureDefault = signedIn(req) ? await sharing.defaultAudience(whoOf(req).requester, 'picture') : 'link';
const description = 'Upload a picture and get a link to share or embed anywhere: a gallery of what people chose to publish, albums, any size or format, and location and camera details removed from every picture.';
const description = 'Upload a picture and get a link to share or embed anywhere: a gallery of what people chose to publish, albums, any size or format, and location and camera details removed from every picture.';
const faq = homeFaq();
const faq = homeFaq();
// The curl sample keeps its line breaks: a backslash and a newline, so it can be copied and run as it is.
// The curl sample keeps its line breaks: a backslash and a newline, so it can be copied and run as it is.
@@ -277,7 +281,7 @@ function createPicsRoutes(ctx) {
@@ -277,7 +281,7 @@ function createPicsRoutes(ctx) {
lede: 'Drop a picture, get a link to share or embed anywhere: on a forum, in a chat, on your site. Browse what people chose to publish, or keep yours unlisted. Location and camera details are removed from every file.',
lede: 'Drop a picture, get a link to share or embed anywhere: on a forum, in a chat, on your site. Browse what people chose to publish, or keep yours unlisted. Location and camera details are removed from every file.',
visual: false,
visual: false,
}))}
}))}
-${canUpload ? uploadForm(req, { compact: true }) : html`<section class="ovp-drop ovp-drop--compact ovp-drop--signin"><div class="ovp-drop-inner"><p class="ovp-drop-title">Sign in to upload</p><p class="ovp-drop-hint">Your OpenVibe account keeps your pictures and albums together, so you can delete them whenever you like.</p><p>${signInLink('/')}</p></div></section>`}
+${canUpload ? uploadForm(req, { compact: true, defaultAudience: pictureDefault }) : html`<section class="ovp-drop ovp-drop--compact ovp-drop--signin"><div class="ovp-drop-inner"><p class="ovp-drop-title">Sign in to upload</p><p class="ovp-drop-hint">Your OpenVibe account keeps your pictures and albums together, so you can delete them whenever you like.</p><p>${signInLink('/')}</p></div></section>`}
${raw(gallery.render(pics, discovery))}
${raw(gallery.render(pics, discovery))}
${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><div class="ovp-section-head"><h2 id="h-latest">Your latest pictures</h2><a href="/mine">All of them</a></div>${grid(mine, 'Nothing yet: your uploads show up here.')}</section>` : ''}
${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><div class="ovp-section-head"><h2 id="h-latest">Your latest pictures</h2><a href="/mine">All of them</a></div>${grid(mine, 'Nothing yet: your uploads show up here.')}</section>` : ''}
<p class="ovp-note">The hidden data is removed by us: your picture arrives with its GPS position, its camera and the time it was taken, and we take all of it out before anyone sees the picture. <a href="/exif">How that works</a>.</p>
<p class="ovp-note">The hidden data is removed by us: your picture arrives with its GPS position, its camera and the time it was taken, and we take all of it out before anyone sees the picture. <a href="/exif">How that works</a>.</p>
@@ -286,7 +290,7 @@ ${raw(showcase.steps({
@@ -286,7 +290,7 @@ ${raw(showcase.steps({
items: [
items: [
{ title: 'Drop a picture', text: `Choose, drag or paste ${FORMAT_NAMES}, up to ${sizeText(cfg.maxImageBytes)}. Several at once works too.` },
{ title: 'Drop a picture', text: `Choose, drag or paste ${FORMAT_NAMES}, up to ${sizeText(cfg.maxImageBytes)}. Several at once works too.` },
{ title: 'Get your links', text: 'A page to share, a direct link, and Markdown, BBCode and HTML ready to copy.' },
{ title: 'Get your links', text: 'A page to share, a direct link, and Markdown, BBCode and HTML ready to copy.' },
- { title: 'Publish when you want', text: 'A picture stays unlisted until you publish it to the gallery. Then it is findable, countable and indexable.' },
+ { title: 'Publish when you want', text: 'A picture stays out of the gallery until you publish it there. Then it is findable, countable and indexable.' },
{ title: 'Use it anywhere', text: 'Post it in a forum or a chat, embed it on your site, or put it in an album. Delete it whenever you like.' },
{ title: 'Use it anywhere', text: 'Post it in a forum or a chat, embed it on your site, or put it in an album. Delete it whenever you like.' },
],
],
}))}
}))}
@@ -294,7 +298,7 @@ ${raw(showcase.features({
@@ -294,7 +298,7 @@ ${raw(showcase.features({
title: 'What you get',
title: 'What you get',
items: [
items: [
{ icon: 'link', title: 'Links that work everywhere', text: 'A page to share, a direct link for an img tag, and Markdown, BBCode and HTML ready to copy.' },
{ icon: 'link', title: 'Links that work everywhere', text: 'A page to share, a direct link for an img tag, and Markdown, BBCode and HTML ready to copy.' },
- { icon: 'shield', title: 'Unlisted by default', text: 'Only people with the link see a picture until you publish it. Nothing is listed, ranked or indexed without you.' },
+ { icon: 'shield', title: 'Your audience choice', text: 'Choose Everyone, anyone with the link, specific people, or only you. Nothing is listed, ranked or indexed without you.' },
{ icon: 'spark', title: 'Clean files', text: 'Every picture is re-encoded, and its GPS position, camera serial and other hidden details are removed before anyone sees it.' },
{ icon: 'spark', title: 'Clean files', text: 'Every picture is re-encoded, and its GPS position, camera serial and other hidden details are removed before anyone sees it.' },
{ icon: 'folder', title: 'Albums', text: 'Put pictures together and share the album with one link.' },
{ icon: 'folder', title: 'Albums', text: 'Put pictures together and share the album with one link.' },
{ icon: 'sliders', title: 'Any size, any format', text: `Every picture also comes at ${variants.LADDER[0]} to ${variants.LADDER[variants.LADDER.length - 1]} pixels wide, as WebP, AVIF, JPEG or PNG, each with a link of its own.` },
{ icon: 'sliders', title: 'Any size, any format', text: `Every picture also comes at ${variants.LADDER[0]} to ${variants.LADDER[variants.LADDER.length - 1]} pixels wide, as WebP, AVIF, JPEG or PNG, each with a link of its own.` },
@@ -341,9 +345,9 @@ ${raw(showcase.network({
@@ -341,9 +345,9 @@ ${raw(showcase.network({
// ── Upload (the no-JavaScript form; the script uses the API) ──
// ── Upload (the no-JavaScript form; the script uses the API) ──
const spoolDir = () => config.tmpDir || path.join(os.tmpdir(), 'mediahub-uploads');
const spoolDir = () => config.tmpDir || path.join(os.tmpdir(), 'mediahub-uploads');
- const uploadFailed = (req, res, detail, status = 422) => page(req, res, {
+ const uploadFailed = async (req, res, detail, status = 422) => page(req, res, {
title: 'Upload failed', scripts: ['js/pics.js'],
title: 'Upload failed', scripts: ['js/pics.js'],
- body: html`<h1>That upload did not work</h1>${notice(detail, 'warn')}${uploadForm(req, { compact: true })}`,
+ body: html`<h1>That upload did not work</h1>${notice(detail, 'warn')}${uploadForm(req, { compact: true, defaultAudience: signedIn(req) ? await sharing.defaultAudience(whoOf(req).requester, 'picture') : 'link' })}`,
}, status);
}, status);
r.post('/upload', budgeted('pics.image.upload'), async (req, res) => {
r.post('/upload', budgeted('pics.image.upload'), async (req, res) => {
@@ -359,7 +363,7 @@ ${raw(showcase.network({
@@ -359,7 +363,7 @@ ${raw(showcase.network({
try {
try {
if (got.error === 'too_large') return uploadFailed(req, res, `A picture can be up to ${formatBytes(max - 64 * 1024)}.`, 413);
if (got.error === 'too_large') return uploadFailed(req, res, `A picture can be up to ${formatBytes(max - 64 * 1024)}.`, 413);
if (got.error || !got.file || !got.file.size) return uploadFailed(req, res, 'No picture arrived. Choose one and send the form again.');
if (got.error || !got.file || !got.file.size) return uploadFailed(req, res, 'No picture arrived. Choose one and send the form again.');
- const out = await pics.upload(whoOf(req), { buffer: fs.readFileSync(got.file.path), name: got.file.filename }, { title: got.fields.title || '' });
+ const out = await pics.upload(whoOf(req), { buffer: fs.readFileSync(got.file.path), name: got.file.filename }, { title: got.fields.title || '', audience: got.fields.audience || undefined });
if (!out.ok) return uploadFailed(req, res, out.detail, out.status);
if (!out.ok) return uploadFailed(req, res, out.detail, out.status);
if (out.deleteKey) return picturePage(req, res, out.image, { deleteKey: out.deleteKey, fresh: true });
if (out.deleteKey) return picturePage(req, res, out.image, { deleteKey: out.deleteKey, fresh: true });
if (out.duplicate) return res.redirect(303, `/i/${out.image.slug}?again=1`);
if (out.duplicate) return res.redirect(303, `/i/${out.image.slug}?again=1`);
@@ -575,21 +579,36 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
@@ -575,21 +579,36 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
const owner = whoOf(req).requester;
const owner = whoOf(req).requester;
const out = await store.imagesOf(s, owner, { limit: 48, before });
const out = await store.imagesOf(s, owner, { limit: 48, before });
const use = await store.usageOf(s, owner);
const use = await store.usageOf(s, owner);
+ const pictureDefault = await sharing.defaultAudience(owner, 'picture');
return page(req, res, {
return page(req, res, {
title: 'Your pictures', scripts: ['js/pics.js'],
title: 'Your pictures', scripts: ['js/pics.js'],
body: html`<div class="ovp-section-head"><h1>Your pictures</h1><span class="muted">${use.images} picture${use.images === 1 ? '' : 's'} · ${formatBytes(use.bytes)} of ${formatBytes(cfg.quotaBytes)}</span></div>
body: html`<div class="ovp-section-head"><h1>Your pictures</h1><span class="muted">${use.images} picture${use.images === 1 ? '' : 's'} · ${formatBytes(use.bytes)} of ${formatBytes(cfg.quotaBytes)}</span></div>
-${uploadForm(req, { compact: true })}
+${uploadForm(req, { compact: true, defaultAudience: pictureDefault })}
+<form class="ovp-form" method="post" action="/mine/share-default"><label>New pictures <select name="audience">${audienceOptions(pictureDefault)}</select></label><label><input type="checkbox" name="apply" value="1"> Apply to all my existing pictures</label><button class="ovp-btn" type="submit">Save default</button></form>
${grid(out.rows, 'Nothing yet. Upload a picture above.')}
${grid(out.rows, 'Nothing yet. Upload a picture above.')}
${out.next ? html`<p><a class="ovp-btn" href="/mine?before=${out.next}">Older pictures</a></p>` : ''}`,
${out.next ? html`<p><a class="ovp-btn" href="/mine?before=${out.next}">Older pictures</a></p>` : ''}`,
});
});
});
});
+ r.post('/mine/share-default', mustSignIn('Sign in to change picture sharing.'), async (req, res) => {
+ if (!sameSite(req, res, '/mine')) return;
+ const audience = String(req.body.audience || '');
+ if (!sharing.AUDIENCES.includes(audience)) return page(req, res, { title: 'Invalid audience', body: html`<h1>Choose a sharing audience</h1><p><a href="/mine">Back to your pictures</a></p>` }, 422);
+ const owner = whoOf(req).requester;
+ await sharing.setDefaultAudience(owner, 'picture', audience);
+ if (req.body.apply === '1') await sharing.applyDefault(owner, 'picture', audience);
+ return res.redirect(303, '/mine');
+ });
+
r.get('/albums', mustSignIn('Albums belong to your OpenVibe account.'), async (req, res) => {
r.get('/albums', mustSignIn('Albums belong to your OpenVibe account.'), async (req, res) => {
- const albums = await store.albumsOf(s, whoOf(req).requester);
+ const owner = whoOf(req).requester;
+ const albums = await store.albumsOf(s, owner);
+ const albumDefault = await sharing.defaultAudience(owner, 'album');
return page(req, res, {
return page(req, res, {
title: 'Your albums',
title: 'Your albums',
body: html`<h1>Your albums</h1>
body: html`<h1>Your albums</h1>
-<form class="ovp-form ovp-inline" method="post" action="/albums"><label for="a-title">New album</label><input id="a-title" name="title" type="text" maxlength="120" required placeholder="Trip to the coast"><button class="ovp-btn ovp-btn--primary" type="submit">Create</button></form>
+<form class="ovp-form ovp-inline" method="post" action="/albums"><label for="a-title">New album</label><input id="a-title" name="title" type="text" maxlength="120" required placeholder="Trip to the coast"><label>Who can open it <select name="audience">${audienceOptions(albumDefault)}</select></label><button class="ovp-btn ovp-btn--primary" type="submit">Create</button></form>
+<form class="ovp-form" method="post" action="/albums/share-default"><label>New albums <select name="audience">${audienceOptions(albumDefault)}</select></label><label><input type="checkbox" name="apply" value="1"> Apply to all my existing albums</label><button class="ovp-btn" type="submit">Save default</button></form>
${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class="ovp-album" href="/a/${a.slug}">${a.cover_slug ? html`<img src="/i/${a.cover_slug}/thumb.webp" alt="" loading="lazy">` : html`<span class="ovp-album-empty" aria-hidden="true"></span>`}<span class="ovp-album-title">${a.title}</span><span class="muted">${a.image_count} picture${Number(a.image_count) === 1 ? '' : 's'}</span></a>`)}</div>`
${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class="ovp-album" href="/a/${a.slug}">${a.cover_slug ? html`<img src="/i/${a.cover_slug}/thumb.webp" alt="" loading="lazy">` : html`<span class="ovp-album-empty" aria-hidden="true"></span>`}<span class="ovp-album-title">${a.title}</span><span class="muted">${a.image_count} picture${Number(a.image_count) === 1 ? '' : 's'}</span></a>`)}</div>`
: html`<p class="muted">No albums yet. Create one here, or add a picture to a new album from its page.</p>`}`,
: html`<p class="muted">No albums yet. Create one here, or add a picture to a new album from its page.</p>`}`,
});
});
@@ -597,11 +616,21 @@ ${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class
@@ -597,11 +616,21 @@ ${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class
r.post('/albums', mustSignIn('Sign in to use albums.'), budgeted('pics.album.create'), async (req, res) => {
r.post('/albums', mustSignIn('Sign in to use albums.'), budgeted('pics.album.create'), async (req, res) => {
if (!sameSite(req, res, '/albums')) return undefined;
if (!sameSite(req, res, '/albums')) return undefined;
- const made = await pics.createAlbum(whoOf(req).requester, { title: req.body.title });
+ const made = await pics.createAlbum(whoOf(req).requester, { title: req.body.title, audience: req.body.audience || undefined });
if (!made.ok) return page(req, res, { title: 'Album', body: html`<h1>Your albums</h1>${notice(made.detail, 'warn')}<p><a href="/albums">Back</a></p>` }, made.status);
if (!made.ok) return page(req, res, { title: 'Album', body: html`<h1>Your albums</h1>${notice(made.detail, 'warn')}<p><a href="/albums">Back</a></p>` }, made.status);
return res.redirect(303, `/a/${made.album.slug}`);
return res.redirect(303, `/a/${made.album.slug}`);
});
});
+ r.post('/albums/share-default', mustSignIn('Sign in to change album sharing.'), async (req, res) => {
+ if (!sameSite(req, res, '/albums')) return;
+ const audience = String(req.body.audience || '');
+ if (!sharing.AUDIENCES.includes(audience)) return page(req, res, { title: 'Invalid audience', body: html`<h1>Choose a sharing audience</h1><p><a href="/albums">Back to your albums</a></p>` }, 422);
+ const owner = whoOf(req).requester;
+ await sharing.setDefaultAudience(owner, 'album', audience);
+ if (req.body.apply === '1') await sharing.applyDefault(owner, 'album', audience);
+ return res.redirect(303, '/albums');
+ });
+
async function ownAlbum(req, res) {
async function ownAlbum(req, res) {
const slug = String(req.params.slug || '');
const slug = String(req.params.slug || '');
const album = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;
const album = pics.isSlug(slug) ? await store.albumBySlug(s, slug) : null;
@@ -618,7 +647,8 @@ ${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class
@@ -618,7 +647,8 @@ ${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class
const share = await sharing.shareOf('album', album.id).catch(() => null);
const share = await sharing.shareOf('album', album.id).catch(() => null);
if (share && !await sharing.canView(req.viewer, 'album', album.id).catch(() => false)) return notFound(req, res);
if (share && !await sharing.canView(req.viewer, 'album', album.id).catch(() => false)) return notFound(req, res);
}
}
- const rows = (await store.albumImages(s, album.id, { visibleOnly: !owner })).filter((i) => owner || i.audience === 'public' || i.audience === 'link');
+ const images = await store.albumImages(s, album.id, { visibleOnly: !owner });
+ const rows = owner ? images : (await Promise.all(images.map(async (image) => await sharing.canView(req.viewer, 'picture', image.id) ? image : null))).filter(Boolean);
return page(req, res, {
return page(req, res, {
title: album.title, noindex: true, scripts: ['js/pics.js'],
title: album.title, noindex: true, scripts: ['js/pics.js'],
image: rows[0] ? `${base}/i/${rows[0].slug}.${rows[0].ext}` : undefined,
image: rows[0] ? `${base}/i/${rows[0].slug}.${rows[0].ext}` : undefined,
@@ -723,7 +753,7 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
@@ -723,7 +753,7 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
markdown: true,
markdown: true,
base,
base,
summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, any size or format.',
summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, any size or format.',
- details: 'Uploads start unlisted: anyone with the link can open a picture. Only explicitly published pictures appear in the public gallery. Each one is re-encoded with its metadata removed, and can be had resized and converted at /i/<slug>/w/<320|640|1280|1920|2560>.<webp|avif|jpg|png>. Alt text: the uploader writes it or asks OpenVibe.AI for a draft (POST /api/v1/images/<slug>/describe). API: POST /api/v1/images with the picture as the body (an OpenVibe sign-in token as a Bearer).',
+ details: 'Uploads use the person\'s audience choice, Everyone by default. Only explicitly published pictures appear in the public gallery. Each one is re-encoded with its metadata removed, and can be had resized and converted at /i/<slug>/w/<320|640|1280|1920|2560>.<webp|avif|jpg|png>. Alt text: the uploader writes it or asks OpenVibe.AI for a draft (POST /api/v1/images/<slug>/describe). API: POST /api/v1/images with the picture as the body (an OpenVibe sign-in token as a Bearer).',
sections: [
sections: [
{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }] },
{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }] },
],
],
@@ -10,14 +10,15 @@ function resumable(ctx, { person, problem, whoOf }) {
@@ -10,14 +10,15 @@ function resumable(ctx, { person, problem, whoOf }) {
r.post('/', limits.budget('pics.image.upload'), express.json({ limit: '8kb' }), async (req, res) => {
r.post('/', limits.budget('pics.image.upload'), express.json({ limit: '8kb' }), async (req, res) => {
const b = req.body || {};
const b = req.body || {};
if (!Number.isInteger(b.size) || b.size < 1 || b.size > config.pics.maxImageBytes || !/^image\/(jpeg|png|webp|gif|avif)$/.test(b.content_type)) return problem(req, res, 422, 'pics.upload_invalid', 'Choose a supported image within the upload limit.');
if (!Number.isInteger(b.size) || b.size < 1 || b.size > config.pics.maxImageBytes || !/^image\/(jpeg|png|webp|gif|avif)$/.test(b.content_type)) return problem(req, res, 422, 'pics.upload_invalid', 'Choose a supported image within the upload limit.');
+ if (b.audience !== undefined && !ctx.sharing.AUDIENCES.includes(b.audience)) return problem(req, res, 422, 'pics.audience', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');
const result = await s.tx(async () => {
const result = await s.tx(async () => {
// A per-owner advisory lock bounds simultaneous unfinished uploads too.
// A per-owner advisory lock bounds simultaneous unfinished uploads too.
await s.db.query('SELECT pg_advisory_xact_lock(hashtext($1))', [whoOf(req).requester]);
await s.db.query('SELECT pg_advisory_xact_lock(hashtext($1))', [whoOf(req).requester]);
await s.db.query('DELETE FROM pics_uploads WHERE expires_at < $1', [s.iso()]);
await s.db.query('DELETE FROM pics_uploads WHERE expires_at < $1', [s.iso()]);
const count = Number(await s.db.value('SELECT count(*) FROM pics_uploads WHERE owner = $1 AND image_id IS NULL', [whoOf(req).requester]));
const count = Number(await s.db.value('SELECT count(*) FROM pics_uploads WHERE owner = $1 AND image_id IS NULL', [whoOf(req).requester]));
if (count >= 4) return null;
if (count >= 4) return null;
- return s.db.maybe(`INSERT INTO pics_uploads (id, owner, size, content_type, name, title, expires_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, ['pup_' + crypto.randomBytes(18).toString('hex'), whoOf(req).requester, b.size, b.content_type, String(b.name || 'picture').slice(0, 200), String(b.title || '').slice(0, 120), new Date(s.now() + 86400000).toISOString()]);
+ return s.db.maybe(`INSERT INTO pics_uploads (id, owner, size, content_type, name, title, audience, expires_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, ['pup_' + crypto.randomBytes(18).toString('hex'), whoOf(req).requester, b.size, b.content_type, String(b.name || 'picture').slice(0, 200), String(b.title || '').slice(0, 120), b.audience || null, new Date(s.now() + 86400000).toISOString()]);
});
});
if (!result) return problem(req, res, 429, 'pics.upload_limit', 'Finish or cancel an upload before starting another.');
if (!result) return problem(req, res, 429, 'pics.upload_limit', 'Finish or cancel an upload before starting another.');
return res.status(201).json(wire(result));
return res.status(201).json(wire(result));
@@ -42,7 +43,7 @@ function resumable(ctx, { person, problem, whoOf }) {
@@ -42,7 +43,7 @@ function resumable(ctx, { person, problem, whoOf }) {
if (u.image_id) { const image = await images.imageById(s, u.image_id); return res.json(pics.toWire(image, { owner: true })); }
if (u.image_id) { const image = await images.imageById(s, u.image_id); return res.json(pics.toWire(image, { owner: true })); }
if (u.offset_bytes !== u.size) return problem(req, res, 409, 'pics.upload_incomplete', 'Upload all bytes before completing.');
if (u.offset_bytes !== u.size) return problem(req, res, 409, 'pics.upload_incomplete', 'Upload all bytes before completing.');
const chunks = await s.db.many('SELECT bytes FROM pics_upload_chunks WHERE upload_id = $1 ORDER BY offset_bytes', [u.id]);
const chunks = await s.db.many('SELECT bytes FROM pics_upload_chunks WHERE upload_id = $1 ORDER BY offset_bytes', [u.id]);
- const out = await pics.upload(whoOf(req), { buffer: Buffer.concat(chunks.map((x) => Buffer.from(x.bytes))), name: u.name }, { title: u.title });
+ const out = await pics.upload(whoOf(req), { buffer: Buffer.concat(chunks.map((x) => Buffer.from(x.bytes))), name: u.name }, { title: u.title, audience: u.audience || undefined });
if (!out.ok) return problem(req, res, out.status, out.code, out.detail);
if (!out.ok) return problem(req, res, out.status, out.code, out.detail);
await s.db.query('UPDATE pics_uploads SET image_id = $2 WHERE id = $1', [u.id, out.image.id]);
await s.db.query('UPDATE pics_uploads SET image_id = $2 WHERE id = $1', [u.id, out.image.id]);
await s.db.query('DELETE FROM pics_upload_chunks WHERE upload_id = $1', [u.id]);
await s.db.query('DELETE FROM pics_upload_chunks WHERE upload_id = $1', [u.id]);
@@ -35,6 +35,7 @@ const REASON_TEXT = {
@@ -35,6 +35,7 @@ const REASON_TEXT = {
private_info: 'Someone\'s private information', spam: 'Spam or a scam', other: 'Something else',
private_info: 'Someone\'s private information', spam: 'Spam or a scam', other: 'Something else',
};
};
const LIMITS = { title: 120, description: 2000, alt: 160, albumTitle: 120, note: 500 };
const LIMITS = { title: 120, description: 2000, alt: 160, albumTitle: 120, note: 500 };
+const AUDIENCES = ['public', 'link', 'people', 'private'];
// A difference hash this close (of 64 bits) to a blocked one is the same picture, resized, re-compressed or retouched:
// A difference hash this close (of 64 bits) to a blocked one is the same picture, resized, re-compressed or retouched:
// measured 2026-10-15, such copies of real photos land 0-10 bits away and different photos 23-42.
// measured 2026-10-15, such copies of real photos land 0-10 bits away and different photos 23-42.
const DHASH_DISTANCE = 10;
const DHASH_DISTANCE = 10;
@@ -88,9 +89,11 @@ function createPics({ s, media, config, log = console, events = null, sharing =
@@ -88,9 +89,11 @@ function createPics({ s, media, config, log = console, events = null, sharing =
* Upload one picture. who: { requester } (a person) or { anonymous: true, ip }. file: { buffer, name }.
* Upload one picture. who: { requester } (a person) or { anonymous: true, ip }. file: { buffer, name }.
* → { ok, image, deleteKey? } or { ok: false, status, code, detail }.
* → { ok, image, deleteKey? } or { ok: false, status, code, detail }.
*/
*/
- async function upload(who, file, { title = '', description = '', keepBoth = false } = {}) {
+ async function upload(who, file, { title = '', description = '', keepBoth = false, audience } = {}) {
const anonymous = !who || !who.requester;
const anonymous = !who || !who.requester;
if (anonymous && !cfg.anonymous) return { ok: false, status: 401, code: 'pics.sign_in_required', detail: 'Sign in with OpenVibe to upload pictures.' };
if (anonymous && !cfg.anonymous) return { ok: false, status: 401, code: 'pics.sign_in_required', detail: 'Sign in with OpenVibe to upload pictures.' };
+ if (audience !== undefined && !AUDIENCES.includes(audience)) return { ok: false, status: 422, code: 'pics.audience', detail: 'Choose Everyone, Anyone with the link, Specific people, or Only me.' };
+ if (anonymous && ['people', 'private'].includes(audience)) return { ok: false, status: 401, code: 'pics.sign_in_required', detail: 'Sign in to make a picture private.' };
const buf = file && file.buffer;
const buf = file && file.buffer;
const max = anonymous ? cfg.anonymousMaxImageBytes : cfg.maxImageBytes;
const max = anonymous ? cfg.anonymousMaxImageBytes : cfg.maxImageBytes;
if (!buf || !buf.length) return { ok: false, status: 422, code: 'pics.empty', detail: 'Choose a picture to upload.' };
if (!buf || !buf.length) return { ok: false, status: 422, code: 'pics.empty', detail: 'Choose a picture to upload.' };
@@ -126,7 +129,7 @@ function createPics({ s, media, config, log = console, events = null, sharing =
@@ -126,7 +129,7 @@ function createPics({ s, media, config, log = console, events = null, sharing =
const thumb = await putObject(prepared.thumb.buffer, { mime: 'image/webp', name: `${id}-thumb.webp`, owner: who && who.requester, anonymous });
const thumb = await putObject(prepared.thumb.buffer, { mime: 'image/webp', name: `${id}-thumb.webp`, owner: who && who.requester, anonymous });
const deleteKey = anonymous ? crypto.randomBytes(18).toString('base64url') : null;
const deleteKey = anonymous ? crypto.randomBytes(18).toString('base64url') : null;
// The row and its mediahub.picture.uploaded event (signed-in uploads only) commit together.
// The row and its mediahub.picture.uploaded event (signed-in uploads only) commit together.
- const row = await s.tx(async () => { const r = await store.insertImage(s, {
+ let row = await s.tx(async () => { const r = await store.insertImage(s, {
id, slug: slug(), owner: anonymous ? null : who.requester,
id, slug: slug(), owner: anonymous ? null : who.requester,
delete_key_hash: deleteKey ? images.sha256(Buffer.from(deleteKey)) : null,
delete_key_hash: deleteKey ? images.sha256(Buffer.from(deleteKey)) : null,
title: base, description: cleanText(description, LIMITS.description),
title: base, description: cleanText(description, LIMITS.description),
@@ -137,9 +140,13 @@ function createPics({ s, media, config, log = console, events = null, sharing =
@@ -137,9 +140,13 @@ function createPics({ s, media, config, log = console, events = null, sharing =
});
});
if (events && !anonymous) await events.pictureUploaded(r);
if (events && !anonymous) await events.pictureUploaded(r);
return r; });
return r; });
- // Unlisted by default: a picture is reachable by its link and by nobody else, until its owner publishes it
- // to the gallery. The share row is the authority, so the page, the file and the thumbnail all agree.
- if (sharing) await sharing.register(row.owner, 'picture', row.id, 'unlisted');
+ // The person's saved choice is the share row authority; anonymous pictures keep link access.
+ const chosen = audience || (anonymous ? 'link' : sharing ? await sharing.defaultAudience(row.owner, 'picture') : 'public');
+ if (sharing) await sharing.register(row.owner, 'picture', row.id, chosen);
+ if (chosen !== 'public') {
+ await s.db.exec('UPDATE pics_images SET audience = $1 WHERE id = $2', [chosen, row.id]);
+ row = await store.imageById(s, row.id);
+ }
return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}), ...(similar ? { similar: { id: similar.id, slug: similar.slug } } : {}) };
return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}), ...(similar ? { similar: { id: similar.id, slug: similar.slug } } : {}) };
}
}
@@ -227,18 +234,28 @@ function createPics({ s, media, config, log = console, events = null, sharing =
@@ -227,18 +234,28 @@ function createPics({ s, media, config, log = console, events = null, sharing =
}
}
// ── Albums (account-only) ───────────────────────────────
// ── Albums (account-only) ───────────────────────────────
- async function createAlbum(owner, { title, description = '' }) {
+ async function createAlbum(owner, { title, description = '', audience } = {}) {
const t = cleanLine(title, LIMITS.albumTitle);
const t = cleanLine(title, LIMITS.albumTitle);
if (!t) return { ok: false, status: 422, code: 'pics.album_title', detail: 'Give the album a name.' };
if (!t) return { ok: false, status: 422, code: 'pics.album_title', detail: 'Give the album a name.' };
+ if (audience !== undefined && !AUDIENCES.includes(audience)) return { ok: false, status: 422, code: 'pics.audience', detail: 'Choose Everyone, Anyone with the link, Specific people, or Only me.' };
if (await store.albumCount(s, owner) >= cfg.albumsMax) return { ok: false, status: 409, code: 'pics.album_limit', detail: `You have ${cfg.albumsMax} albums, the most you can.` };
if (await store.albumCount(s, owner) >= cfg.albumsMax) return { ok: false, status: 409, code: 'pics.album_limit', detail: `You have ${cfg.albumsMax} albums, the most you can.` };
const row = await store.insertAlbum(s, { id: s.newId('alb'), slug: slug(), owner, title: t, description: cleanText(description, LIMITS.description), created_at: s.iso() });
const row = await store.insertAlbum(s, { id: s.newId('alb'), slug: slug(), owner, title: t, description: cleanText(description, LIMITS.description), created_at: s.iso() });
- if (sharing) await sharing.register(owner, 'album', row.id, 'public');
- return { ok: true, album: row };
+ const chosen = audience || (sharing ? await sharing.defaultAudience(owner, 'album') : 'public');
+ if (sharing) await sharing.register(owner, 'album', row.id, chosen);
+ if (chosen !== 'public') await s.db.exec('UPDATE pics_albums SET audience = $1 WHERE id = $2', [chosen, row.id]);
+ return { ok: true, album: chosen === 'public' ? row : await store.albumBySlug(s, row.slug) };
}
}
async function addToAlbum(album, image) {
async function addToAlbum(album, image) {
if (await store.albumImageCount(s, album.id) >= cfg.albumImagesMax) return { ok: false, status: 409, code: 'pics.album_full', detail: `An album holds ${cfg.albumImagesMax} pictures.` };
if (await store.albumImageCount(s, album.id) >= cfg.albumImagesMax) return { ok: false, status: 409, code: 'pics.album_full', detail: `An album holds ${cfg.albumImagesMax} pictures.` };
- await store.addToAlbum(s, album.id, image.id);
+ await s.tx(async () => {
+ await store.addToAlbum(s, album.id, image.id);
+ const reach = { public: 3, link: 2, people: 1, private: 0 };
+ // Filing a picture in an album may narrow it, never expose it to more people.
+ if (sharing && album.audience && reach[album.audience] < reach[image.audience || 'public']) {
+ await sharing.setAudience({ subject: album.owner.slice(5) }, 'picture', image.id, album.audience);
+ }
+ });
return { ok: true };
return { ok: true };
}
}
@@ -264,7 +281,7 @@ function createPics({ s, media, config, log = console, events = null, sharing =
@@ -264,7 +281,7 @@ function createPics({ s, media, config, log = console, events = null, sharing =
// L1 labels: `{ labels, suggested }` (common.content-labels@1). A suggestion is shown, never applied.
// L1 labels: `{ labels, suggested }` (common.content-labels@1). A suggestion is shown, never applied.
content_labels: contentLabels.labelsOf(row),
content_labels: contentLabels.labelsOf(row),
listed: Boolean(row.listed), tags: JSON.parse(row.tags || '[]'),
listed: Boolean(row.listed), tags: JSON.parse(row.tags || '[]'),
- state: row.state, created_at: row.created_at, links: links(row),
+ state: row.state, audience: row.audience || 'public', created_at: row.created_at, links: links(row),
...(owner ? { report_count: Number(row.report_count) } : {}),
...(owner ? { report_count: Number(row.report_count) } : {}),
});
});
@@ -44,7 +44,7 @@ const subjectOf = (requester) => {
@@ -44,7 +44,7 @@ const subjectOf = (requester) => {
return null;
return null;
};
};
-function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {
+function createSharing({ db, config, outbox, media, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {
const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');
const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');
const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({
const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({
tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
@@ -103,6 +103,11 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
@@ -103,6 +103,11 @@ 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';
+ const row = await tx.maybe('SELECT media_vod_id FROM video_uploads WHERE id = $1', [ref.id]);
+ if (row && media) {
+ const result = await media.vodUpdate(row.media_vod_id, { visibility: vis });
+ if (result && result.ok === false && result.status !== 404) throw new ShareError(502, 'share.media_unavailable', 'Video storage could not change the visibility');
+ }
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') {
} else if (ref.kind === 'download') {
await tx.exec('UPDATE mh_files SET share_v1 = true WHERE id = $1', [ref.id]);
await tx.exec('UPDATE mh_files SET share_v1 = true WHERE id = $1', [ref.id]);
@@ -130,24 +135,38 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
@@ -130,24 +135,38 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
const at = (kind, id) => refOf(kind, id);
const at = (kind, id) => refOf(kind, id);
async function defaultAudience(owner, kind) {
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]);
+ if (!KINDS.includes(kind)) throw new Error('Unknown share kind');
+ const table = kind === 'download' ? 'mh_share_defaults' : 'mh_content_share_defaults';
+ const row = await db.maybe(`SELECT audience FROM ${table} WHERE owner = $1 AND kind = $2`, [owner, kind]);
return row ? row.audience : 'public';
return row ? row.audience : 'public';
}
}
async function setDefaultAudience(owner, kind, audience) {
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)
+ if (!KINDS.includes(kind) || !AUDIENCES.includes(audience)) throw new Error('Invalid share default');
+ const table = kind === 'download' ? 'mh_share_defaults' : 'mh_content_share_defaults';
+ await db.exec(`INSERT INTO ${table} (owner, kind, audience) VALUES ($1, $2, $3)
ON CONFLICT (owner, kind) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [owner, kind, audience]);
ON CONFLICT (owner, kind) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [owner, kind, audience]);
}
}
async function applyDefault(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]);
+ if (!KINDS.includes(kind) || !AUDIENCES.includes(audience)) throw new Error('Invalid share default');
+ const queries = {
+ picture: "SELECT id, audience FROM pics_images WHERE owner = $1 AND state = 'active'",
+ album: 'SELECT id, audience FROM pics_albums WHERE owner = $1',
+ video: "SELECT id, visibility FROM video_uploads WHERE owner = $1 AND state = 'active'",
+ download: 'SELECT id FROM mh_files WHERE owner = $1 AND deleted_at IS NULL AND binned_at IS NULL',
+ };
+ const rows = await db.many(queries[kind], [owner]);
const viewer = { subject: subjectOf(owner) };
const viewer = { subject: subjectOf(owner) };
let changed = 0;
let changed = 0;
for (const row of rows) {
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; }
+ const current = await db.maybe('SELECT audience, owner_subject FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, kind, row.id]);
+ if (current && (current.owner_subject.type !== 'user' || current.owner_subject.id !== subjectOf(owner).id)) continue;
+ if (!current) {
+ const oldAudience = kind === 'video' ? TO_SDK[row.visibility] || 'private' : row.audience || 'private';
+ await shares.register(at(kind, row.id), { owner_subject: subjectOf(owner), audience: oldAudience });
+ if (oldAudience === audience) continue;
+ }
if (current && current.audience !== audience) { await shares.setAudience(viewer, at(kind, row.id), audience); changed++; }
if (current && current.audience !== audience) { await shares.setAudience(viewer, at(kind, row.id), audience); changed++; }
+ else if (!current) { await shares.setAudience(viewer, at(kind, row.id), audience); changed++; }
}
}
return changed;
return changed;
}
}
@@ -162,7 +181,19 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
@@ -162,7 +181,19 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
}),
}),
defaultAudience, setDefaultAudience, applyDefault,
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) => {
+ const person = sv(viewer);
+ if (await shares.canView(person, at(kind, id))) return true;
+ // A named album reader can open pictures that follow that album's People audience.
+ // A picture narrowed to Only me keeps its own veto, even inside a shared album.
+ if (kind !== 'picture') return false;
+ const picture = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, 'picture', id]);
+ if (!picture || picture.audience !== 'people') return false;
+ const albums = await db.many(`SELECT a.id FROM pics_album_images ai JOIN pics_albums a ON a.id = ai.album_id
+ WHERE ai.image_id = $1 AND a.audience = 'people'`, [id]);
+ for (const album of albums) if (await shares.canView(person, at('album', album.id))) return true;
+ return false;
+ },
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)),
get: async (viewer, kind, id) => await shares.get(sv(viewer), at(kind, id)),
get: async (viewer, kind, id) => await shares.get(sv(viewer), at(kind, id)),
getWithRevision: async (viewer, kind, id) => await shares.getWithRevision(sv(viewer), at(kind, id)),
getWithRevision: async (viewer, kind, id) => await shares.getWithRevision(sv(viewer), at(kind, id)),
@@ -955,6 +955,20 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -955,6 +955,20 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
});
});
r.use('/api/v1/video', createVideoApiRoutes({ ...ctx, live }));
r.use('/api/v1/video', createVideoApiRoutes({ ...ctx, live }));
+ r.get('/api/v1/share-defaults/video', ctx.principal.middleware, async (req, res) => {
+ if (req.principal.kind !== 'user') return res.status(401).json({ code: 'auth.required', detail: 'Sign in to see your video default.' });
+ res.set('Cache-Control', 'no-store').json({ kind: 'video', audience: await ctx.sharing.defaultAudience(req.principal.requester, 'video') });
+ });
+ r.put('/api/v1/share-defaults/video', ctx.principal.middleware, express.json({ limit: '8kb' }), async (req, res) => {
+ if (req.principal.kind !== 'user') return res.status(401).json({ code: 'auth.required', detail: 'Sign in to change your video default.' });
+ if (req.principal.viaSession && !sameOrigin(req, config.video.url)) return res.status(403).json({ code: 'request.cross_site' });
+ const audience = String((req.body || {}).audience || '');
+ if (!ctx.sharing.AUDIENCES.includes(audience)) return res.status(422).json({ code: 'share.default_invalid' });
+ const owner = req.principal.requester;
+ await ctx.sharing.setDefaultAudience(owner, 'video', audience);
+ const changed = req.body.apply === true ? await ctx.sharing.applyDefault(owner, 'video', audience) : 0;
+ return res.set('Cache-Control', 'no-store').json({ kind: 'video', audience, changed });
+ });
// Uploads' part API (server/video/upload-routes.js): raw parts and its own JSON, before the forms' parser.
// Uploads' part API (server/video/upload-routes.js): raw parts and its own JSON, before the forms' parser.
r.use('/api/v1/videos', createUploadApi({ ctx, config }));
r.use('/api/v1/videos', createUploadApi({ ctx, config }));
// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
@@ -24,6 +24,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -24,6 +24,7 @@ const cache = require('openvibe-shared/cache-policy');
const { sameOrigin } = require('../http/principal');
const { sameOrigin } = require('../http/principal');
const { html, raw, notice, askAgent } = require('../render/html');
const { html, raw, notice, askAgent } = require('../render/html');
const { UploadError } = require('./uploads');
const { UploadError } = require('./uploads');
+const { ShareError } = require('openvibe-sdk/share');
const REASON_TEXT = {
const REASON_TEXT = {
child_safety: 'It sexualises or endangers a child', violence: 'Violence or a threat', harassment: 'Harassment or someone\'s private details',
child_safety: 'It sexualises or endangers a child', violence: 'Violence or a threat', harassment: 'Harassment or someone\'s private details',
@@ -31,7 +32,7 @@ const REASON_TEXT = {
@@ -31,7 +32,7 @@ const REASON_TEXT = {
};
};
function problem(res, err) {
function problem(res, err) {
- const e = err instanceof UploadError ? err : null;
+ const e = err instanceof UploadError || err instanceof ShareError ? err : null;
if (!e) throw err;
if (!e) throw err;
return res.status(e.status).type('application/problem+json').json({ type: 'about:blank', status: e.status, code: e.code, detail: e.detail });
return res.status(e.status).type('application/problem+json').json({ type: 'about:blank', status: e.status, code: e.code, detail: e.detail });
}
}
@@ -61,7 +62,7 @@ function createUploadApi({ ctx, config }) {
@@ -61,7 +62,7 @@ function createUploadApi({ ctx, config }) {
if (!guard(req, res)) return undefined;
if (!guard(req, res)) return undefined;
try {
try {
const b = req.body || {};
const b = req.body || {};
- const row = await up.start(who(req), { title: b.title, description: b.description, visibility: b.visibility, size: b.size, contentType: b.content_type, name: b.name });
+ const row = await up.start(who(req), { title: b.title, description: b.description, visibility: b.visibility, audience: b.audience, size: b.size, contentType: b.content_type, name: b.name });
return res.status(201).json({ upload: { id: row.id, part_size: row.part_size, parts_expected: row.parts_expected } });
return res.status(201).json({ upload: { id: row.id, part_size: row.part_size, parts_expected: row.parts_expected } });
} catch (err) { return problem(res, err); }
} catch (err) { return problem(res, err); }
});
});
@@ -79,7 +80,7 @@ function createUploadApi({ ctx, config }) {
@@ -79,7 +80,7 @@ function createUploadApi({ ctx, config }) {
if (!row) return undefined;
if (!row) return undefined;
try {
try {
const b = req.body || {};
const b = req.body || {};
- const updated = await up.update(row, { title: b.title, description: b.description, visibility: b.visibility });
+ const updated = await up.update(row, { title: b.title, description: b.description, visibility: b.visibility, audience: b.audience });
return res.json({ upload: { id: updated.id, title: updated.title, visibility: updated.visibility } });
return res.json({ upload: { id: updated.id, title: updated.title, visibility: updated.visibility } });
} catch (err) { return problem(res, err); }
} catch (err) { return problem(res, err); }
});
});
@@ -106,7 +107,7 @@ function createUploadApi({ ctx, config }) {
@@ -106,7 +107,7 @@ function createUploadApi({ ctx, config }) {
if (!guard(req, res)) return undefined;
if (!guard(req, res)) return undefined;
try {
try {
const b = req.body || {};
const b = req.body || {};
- const row = await up.startRecording(who(req), { title: b.title, description: b.description, visibility: b.visibility, contentType: b.content_type });
+ const row = await up.startRecording(who(req), { title: b.title, description: b.description, visibility: b.visibility, audience: b.audience, contentType: b.content_type });
return res.status(201).json({ upload: { id: row.id } });
return res.status(201).json({ upload: { id: row.id } });
} catch (err) { return problem(res, err); }
} catch (err) { return problem(res, err); }
});
});
@@ -166,6 +167,8 @@ function mountUploadPages(r, h) {
@@ -166,6 +167,8 @@ function mountUploadPages(r, h) {
const cfg = ctx.config.videoUploads;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
const GB = 1024 ** 3;
const GB = 1024 ** 3;
+ const audienceOptions = (current) => [['public', 'Everyone'], ['link', 'Anyone with the link'], ['people', 'Specific people'], ['private', 'Only me']]
+ .map(([value, label]) => html`<option value="${value}"${value === current ? raw(' selected') : ''}>${label}</option>`);
const statusText = (row) => (row.state === 'hidden' ? 'Hidden after reports, waiting for staff'
const statusText = (row) => (row.state === 'hidden' ? 'Hidden after reports, waiting for staff'
: row.status === 'uploading' ? `Upload stopped at ${Math.round((row.parts_received / row.parts_expected) * 100)}%`
: row.status === 'uploading' ? `Upload stopped at ${Math.round((row.parts_received / row.parts_expected) * 100)}%`
@@ -246,10 +249,8 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
@@ -246,10 +249,8 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
<div class="ovv-upload-details">
<div class="ovv-upload-details">
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" required></label>
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" required></label>
<label class="ovv-field"><span>Description</span><textarea name="description" rows="4" maxlength="5000"></textarea></label>
<label class="ovv-field"><span>Description</span><textarea name="description" rows="4" maxlength="5000"></textarea></label>
-<fieldset class="ovv-visibility"><legend>Who can see it?</legend>
-<label><input type="radio" name="visibility" value="public" checked><span><b>Public</b><small>Anyone can find and watch it.</small></span></label>
-<label><input type="radio" name="visibility" value="unlisted"><span><b>Unlisted</b><small>Only people with the link.</small></span></label>
-<label><input type="radio" name="visibility" value="private"><span><b>Only you</b><small>Your private video.</small></span></label></fieldset>
+<label class="ovv-field"><span>Who can see it?</span><select name="audience">${audienceOptions(await ctx.sharing.defaultAudience(ownerOf(req), 'video'))}</select></label>
+<p class="ovv-fine">For Specific people, name viewers on the video's Share sheet after upload.</p>
<p class="ovv-fine">Upload only what you may share. <button type="button" class="ovv-link-button" data-upload-rules>Rules and limits</button></p>
<p class="ovv-fine">Upload only what you may share. <button type="button" class="ovv-link-button" data-upload-rules>Rules and limits</button></p>
<p class="ovv-upload-status" role="status" aria-live="polite"></p>
<p class="ovv-upload-status" role="status" aria-live="polite"></p>
<button class="ovv-btn ovv-btn--primary" type="submit" disabled>Save details</button>
<button class="ovv-btn ovv-btn--primary" type="submit" disabled>Save details</button>
@@ -268,7 +269,7 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
@@ -268,7 +269,7 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
<p class="ovv-lede">Open the camera here, then review and trim your clip before saving it to Video.</p>
<p class="ovv-lede">Open the camera here, then review and trim your clip before saving it to Video.</p>
<form class="ovv-record ovv-panel" data-video-upload data-video-record>
<form class="ovv-record ovv-panel" data-video-upload data-video-record>
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" placeholder="Untitled recording"></label>
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" placeholder="Untitled recording"></label>
-<label class="ovv-field"><span>Who can see it</span><select name="visibility">${visibilityOptions('private')}</select></label>
+<label class="ovv-field"><span>Who can see it</span><select name="audience">${audienceOptions(await ctx.sharing.defaultAudience(ownerOf(req), 'video'))}</select></label>
<input name="file" type="file" accept="video/webm,video/mp4" class="ovv-record-file" aria-label="Recorded file" tabindex="-1" required>
<input name="file" type="file" accept="video/webm,video/mp4" class="ovv-record-file" aria-label="Recorded file" tabindex="-1" required>
<textarea name="description" hidden></textarea>
<textarea name="description" hidden></textarea>
<progress hidden value="0" max="1"></progress>
<progress hidden value="0" max="1"></progress>
@@ -292,6 +293,7 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
@@ -292,6 +293,7 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
const rows = await up.mine(ownerOf(req));
const rows = await up.mine(ownerOf(req));
await Promise.all(rows.filter((x) => x.status === 'processing').map((x) => up.refresh(x).catch(() => null)));
await Promise.all(rows.filter((x) => x.status === 'processing').map((x) => up.refresh(x).catch(() => null)));
const fresh = await up.mine(ownerOf(req));
const fresh = await up.mine(ownerOf(req));
+ const defaultAudience = await ctx.sharing.defaultAudience(ownerOf(req), 'video');
return page(req, res, {
return page(req, res, {
title: 'Your videos',
title: 'Your videos',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your videos' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your videos' }],
@@ -303,6 +305,7 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
@@ -303,6 +305,7 @@ ${cuts.length ? html`<ul class="ovv-cuts">${cuts.map(cutLine)}</ul>` : ''}
? `${fresh.length} video${fresh.length === 1 ? '' : 's'}, newest first. A processing video finishes in the background, so you can close the page.`
? `${fresh.length} video${fresh.length === 1 ? '' : 's'}, newest first. A processing video finishes in the background, so you can close the page.`
: 'Everything you upload to OpenVibe.Video appears here, with its state and who can see it.'}</p>
: 'Everything you upload to OpenVibe.Video appears here, with its state and who can see it.'}</p>
<a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a></div>
<a class="ovv-btn ovv-btn--primary" href="/upload"><i class="fa-solid fa-upload" aria-hidden="true"></i> Upload a video</a></div>
+<form class="ovv-panel" method="post" action="/me/videos/share-default"><label>New videos <select name="audience">${audienceOptions(defaultAudience)}</select></label> <label><input type="checkbox" name="apply" value="1"> Apply to all my existing videos</label> <button class="ovv-btn" type="submit">Save default</button></form>
${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : html`<div class="ovv-my-empty"><i class="fa-solid fa-film" aria-hidden="true"></i><h2>No videos yet</h2><p>Choose a video and the upload starts while you fill in the title.</p><a class="ovv-btn ovv-btn--primary" href="/upload">Upload your first video</a></div>`}
${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : html`<div class="ovv-my-empty"><i class="fa-solid fa-film" aria-hidden="true"></i><h2>No videos yet</h2><p>Choose a video and the upload starts while you fill in the title.</p><a class="ovv-btn ovv-btn--primary" href="/upload">Upload your first video</a></div>`}
<dialog class="ovv-rules" data-delete-sheet aria-label="Delete this video">
<dialog class="ovv-rules" data-delete-sheet aria-label="Delete this video">
<h2>Delete this video?</h2>
<h2>Delete this video?</h2>
@@ -313,6 +316,16 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
@@ -313,6 +316,16 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
});
});
});
});
+ r.post('/me/videos/share-default', mustSignIn('/me/videos', 'Sign in to change video sharing.'), async (req, res) => {
+ if (!sameSite(req, res, '/me/videos')) return;
+ const audience = String(req.body.audience || '');
+ if (!ctx.sharing.AUDIENCES.includes(audience)) return res.status(422).type('text/plain').send('Choose a sharing audience.');
+ const owner = ownerOf(req);
+ await ctx.sharing.setDefaultAudience(owner, 'video', audience);
+ if (req.body.apply === '1') await ctx.sharing.applyDefault(owner, 'video', audience);
+ return res.redirect(303, '/me/videos');
+ });
+
// The numbered address answers 301 to the watch address, so an old link or card still lands on the video.
// The numbered address answers 301 to the watch address, so an old link or card still lands on the video.
r.get('/u/:id', async (req, res) => {
r.get('/u/:id', async (req, res) => {
let row = await visibleUpload(req, res, String(req.params.id || ''));
let row = await visibleUpload(req, res, String(req.params.id || ''));
@@ -324,18 +337,25 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
@@ -324,18 +337,25 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
return res.redirect(301, `/watch?v=${watchId}${tail}`);
return res.redirect(301, `/watch?v=${watchId}${tail}`);
});
});
- /** The upload a request may see (its owner, staff, a share's people, the public), or null (and 404). */
- async function visibleUpload(req, res, id) {
- const row = await up.byId(id);
+ async function maySeeUpload(req, row) {
const who = whoOf(req);
const who = whoOf(req);
- if (!up.canSee(row, who)) { notFound(req, res); return null; }
+ if (!row || row.state === 'removed') return false;
// Specific people and Only me: the share row decides, beside the old column. A row written before the
// Specific people and Only me: the share row decides, beside the old column. A row written before the
// sharing model (an import, a cut) does not exist yet: the column still decides, failing closed for private.
// sharing model (an import, a cut) does not exist yet: the column still decides, failing closed for private.
const own = Boolean(who.requester && row.owner === who.requester);
const own = Boolean(who.requester && row.owner === who.requester);
- if (!own && !who.staff && ctx.sharing) {
+ if (own || who.staff) return true;
+ if (row.state !== 'active' || row.status !== 'ready') return false;
+ if (ctx.sharing) {
const share = await ctx.sharing.shareOf('video', row.id).catch(() => null);
const share = await ctx.sharing.shareOf('video', row.id).catch(() => null);
- if (share && !await ctx.sharing.canView(req.viewer, 'video', row.id).catch(() => false)) { notFound(req, res); return null; }
+ if (share) return await ctx.sharing.canView(req.viewer, 'video', row.id).catch(() => false);
}
}
+ return up.canSee(row, who);
+ }
+
+ /** The upload a request may see (its owner, staff, a share's people, the public), or null (and 404). */
+ async function visibleUpload(req, res, id) {
+ const row = await up.byId(id);
+ if (!await maySeeUpload(req, row)) { notFound(req, res); return null; }
return row;
return row;
}
}
@@ -380,6 +400,8 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
@@ -380,6 +400,8 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
const who = whoOf(req);
const who = whoOf(req);
const own = Boolean(who.requester && row.owner === who.requester);
const own = Boolean(who.requester && row.owner === who.requester);
const manage = own || who.staff;
const manage = own || who.staff;
+ const share = manage && ctx.sharing ? await ctx.sharing.shareOf('video', row.id).catch(() => null) : null;
+ const currentAudience = share ? share.audience : row.visibility === 'unlisted' ? 'link' : row.visibility;
const ready = row.status === 'ready';
const ready = row.status === 'ready';
const vod = ready ? await up.playback(row) : null;
const vod = ready ? await up.playback(row) : null;
const ai = ready ? await ctx.videoAi.forUpload(row) : null;
const ai = ready ? await ctx.videoAi.forUpload(row) : null;
@@ -445,7 +467,7 @@ ${what}
@@ -445,7 +467,7 @@ ${what}
${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}
${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}
<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
</div>
</div>
-<div class="ovv-byline-actions">${saveForm}${ready && vod && fileOf(vod) ? html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(fileOf(vod))}&source_url=${encodeURIComponent(`${String(ctx.config.video.url).replace(/\/+$/, '')}${watchPath}`)}&title=${encodeURIComponent(String(row.title || 'Watch party'))}"><i class="fa-solid fa-users" aria-hidden="true"></i> Watch together</a>` : ''}${own && ready && row.state === 'active' && studioVideoEdit ? html`<a class="ovk-btn ovk-btn--quiet" href="${studioEditUrl(row)}">Edit in Studio</a>` : ''}${raw(shareButton({ cls: 'ovv-btn', url: `${ctx.config.video.url}${watchPath}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null }))}${raw(askAgent({ text: `About the video “${row.title}” on OpenVibe.Video (${ctx.config.video.url}${watchPath}): ` }))}</div>
+<div class="ovv-byline-actions">${saveForm}${ready && vod && fileOf(vod) ? html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(fileOf(vod))}&source_url=${encodeURIComponent(`${String(ctx.config.video.url).replace(/\/+$/, '')}${watchPath}`)}&title=${encodeURIComponent(String(row.title || 'Watch party'))}"><i class="fa-solid fa-users" aria-hidden="true"></i> Watch together</a>` : ''}${own && ready && row.state === 'active' && studioVideoEdit ? html`<a class="ovk-btn ovk-btn--quiet" href="${studioEditUrl(row)}">Edit in Studio</a>` : ''}${raw(shareButton({ cls: 'ovv-btn', url: `${ctx.config.video.url}${watchPath}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null, kind: 'video', id: row.id }))}${raw(askAgent({ text: `About the video “${row.title}” on OpenVibe.Video (${ctx.config.video.url}${watchPath}): ` }))}</div>
</div>
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>
${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>
@@ -459,7 +481,8 @@ ${manage ? html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><
@@ -459,7 +481,8 @@ ${manage ? html`<section class="ovv-panel ovv-owner" aria-labelledby="h-owner"><
<form class="ovv-owner-form" method="post" action="/u/${row.id}/manage">
<form class="ovv-owner-form" method="post" action="/u/${row.id}/manage">
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" value="${row.title}" required></label>
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="120" value="${row.title}" required></label>
<label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="5000">${row.description}</textarea></label>
<label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="5000">${row.description}</textarea></label>
-<label class="ovv-field"><span>Visibility</span><select name="visibility">${visibilityOptions(row.visibility)}</select></label>
+${own ? html`<label class="ovv-field"><span>Who can see it</span><select name="audience">${audienceOptions(currentAudience)}</select></label>`
+ : html`<label class="ovv-field"><span>Visibility</span><select name="visibility">${visibilityOptions(row.visibility)}</select></label>`}
<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button></form>
<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button></form>
<details class="ovv-danger"><summary>Delete this video</summary>
<details class="ovv-danger"><summary>Delete this video</summary>
<form class="ovv-owner-delete" method="post" action="/u/${row.id}/delete"><label class="ovv-confirm"><input type="checkbox" name="confirm" value="1" required> I want to delete it for good</label> <button class="ovv-btn ovv-btn--danger" type="submit">Delete</button></form>
<form class="ovv-owner-delete" method="post" action="/u/${row.id}/delete"><label class="ovv-confirm"><input type="checkbox" name="confirm" value="1" required> I want to delete it for good</label> <button class="ovv-btn ovv-btn--danger" type="submit">Delete</button></form>
@@ -480,7 +503,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
@@ -480,7 +503,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
// The AI's transcript and chapters as WebVTT tracks, for whoever may see the video.
// The AI's transcript and chapters as WebVTT tracks, for whoever may see the video.
async function aiOf(req) {
async function aiOf(req) {
const row = await up.byId(req.params.id);
const row = await up.byId(req.params.id);
- if (!up.canSee(row, whoOf(req)) || row.status !== 'ready') return null;
+ if (!await maySeeUpload(req, row) || row.status !== 'ready') return null;
const ai = await ctx.videoAi.forUpload(row);
const ai = await ctx.videoAi.forUpload(row);
return ai && ai.status === 'done' ? { row, ai } : null;
return ai && ai.status === 'done' ? { row, ai } : null;
}
}
@@ -500,9 +523,9 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
@@ -500,9 +523,9 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
if (!sameSite(req, res, `/u/${row.id}`)) return undefined;
if (!sameSite(req, res, `/u/${row.id}`)) return undefined;
try {
try {
const b = req.body || {};
const b = req.body || {};
- await up.update(row, { title: b.title === undefined ? undefined : b.title, description: b.description, visibility: b.visibility === undefined ? undefined : b.visibility });
+ await up.update(row, { title: b.title === undefined ? undefined : b.title, description: b.description, visibility: b.visibility === undefined ? undefined : b.visibility, audience: b.audience === undefined ? undefined : b.audience });
} catch (err) {
} catch (err) {
- if (!(err instanceof UploadError)) throw err;
+ if (!(err instanceof UploadError || err instanceof ShareError)) throw err;
return page(req, res, { title: 'Not saved', body: html`<h1>Not saved</h1><p>${err.detail}</p><p><a href="${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}">Back</a></p>` }, err.status);
return page(req, res, { title: 'Not saved', body: html`<h1>Not saved</h1><p>${err.detail}</p><p><a href="${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}">Back</a></p>` }, err.status);
}
}
if (ctx.videoSearch) ctx.videoSearch.kick();
if (ctx.videoSearch) ctx.videoSearch.kick();
@@ -536,7 +559,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
@@ -536,7 +559,7 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
r.post('/u/:id/report', async (req, res) => {
r.post('/u/:id/report', async (req, res) => {
const row = await up.byId(req.params.id);
const row = await up.byId(req.params.id);
- if (!up.canSee(row, whoOf(req))) return notFound(req, res);
+ if (!await maySeeUpload(req, row)) return notFound(req, res);
if (!sameSite(req, res, `/u/${row.id}`)) return undefined;
if (!sameSite(req, res, `/u/${row.id}`)) return undefined;
try {
try {
const b = req.body || {};
const b = req.body || {};
@@ -27,6 +27,9 @@ const crypto = require('crypto');
@@ -27,6 +27,9 @@ const crypto = require('crypto');
const KINDS = { 'video/mp4': 'video/mp4', 'video/quicktime': 'video/mp4', 'video/x-m4v': 'video/mp4', 'video/webm': 'video/webm', 'video/x-matroska': 'video/x-matroska' };
const KINDS = { 'video/mp4': 'video/mp4', 'video/quicktime': 'video/mp4', 'video/x-m4v': 'video/mp4', 'video/webm': 'video/webm', 'video/x-matroska': 'video/x-matroska' };
const EXT_KINDS = { mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska' };
const EXT_KINDS = { mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska' };
const VISIBILITY = ['public', 'unlisted', 'private'];
const VISIBILITY = ['public', 'unlisted', 'private'];
+const AUDIENCE = ['public', 'link', 'people', 'private'];
+const visibilityOf = (audience) => audience === 'public' ? 'public' : audience === 'link' ? 'unlisted' : 'private';
+const audienceOf = (visibility) => visibility === 'public' ? 'public' : visibility === 'unlisted' ? 'link' : 'private';
const REASONS = ['child_safety', 'violence', 'harassment', 'sexual', 'copyright', 'spam', 'other'];
const REASONS = ['child_safety', 'violence', 'harassment', 'sexual', 'copyright', 'spam', 'other'];
const PART_SIZE = 8 * 1024 * 1024;
const PART_SIZE = 8 * 1024 * 1024;
const HIDE_AFTER = 3;
const HIDE_AFTER = 3;
@@ -106,7 +109,9 @@ function createUploads({ s, media, config, log = console, events = null, sharing
@@ -106,7 +109,9 @@ function createUploads({ s, media, config, log = console, events = null, sharing
const title = cleanLine(input.title, 120);
const title = cleanLine(input.title, 120);
if (!title) throw new UploadError(422, 'upload.title_required', 'Give the video a title.');
if (!title) throw new UploadError(422, 'upload.title_required', 'Give the video a title.');
const description = cleanText(input.description, 5000);
const description = cleanText(input.description, 5000);
- const visibility = VISIBILITY.includes(input.visibility) ? input.visibility : 'public';
+ if (input.audience !== undefined && !AUDIENCE.includes(input.audience)) throw new UploadError(422, 'upload.audience', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');
+ const audience = input.audience || (VISIBILITY.includes(input.visibility) ? audienceOf(input.visibility) : sharing ? await sharing.defaultAudience(who.requester, 'video') : 'public');
+ const visibility = visibilityOf(audience);
const size = Number(input.size);
const size = Number(input.size);
const kind = kindOf(input.contentType, input.name);
const kind = kindOf(input.contentType, input.name);
if (!kind) throw new UploadError(415, 'upload.type', 'Upload an MP4, MOV, WebM or MKV video.');
if (!kind) throw new UploadError(415, 'upload.type', 'Upload an MP4, MOV, WebM or MKV video.');
@@ -125,7 +130,7 @@ function createUploads({ s, media, config, log = console, events = null, sharing
@@ -125,7 +130,7 @@ function createUploads({ s, media, config, log = console, events = null, sharing
await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at, watch_id)
await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at, watch_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12, $13)`,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12, $13)`,
[id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, size, PART_SIZE, Math.ceil(size / PART_SIZE), now, watchId]);
[id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, size, PART_SIZE, Math.ceil(size / PART_SIZE), now, watchId]);
- if (sharing) await sharing.register(who.requester, 'video', id, visibility);
+ if (sharing) await sharing.register(who.requester, 'video', id, audience);
return await byId(id);
return await byId(id);
}
}
@@ -133,7 +138,9 @@ function createUploads({ s, media, config, log = console, events = null, sharing
@@ -133,7 +138,9 @@ function createUploads({ s, media, config, log = console, events = null, sharing
if (!who || !who.requester) throw new UploadError(401, 'auth.required', 'Sign in to record a video.');
if (!who || !who.requester) throw new UploadError(401, 'auth.required', 'Sign in to record a video.');
const title = cleanLine(input.title, 120) || `Recording, ${s.iso().slice(0, 16).replace('T', ' ')} UTC`;
const title = cleanLine(input.title, 120) || `Recording, ${s.iso().slice(0, 16).replace('T', ' ')} UTC`;
const description = cleanText(input.description, 5000);
const description = cleanText(input.description, 5000);
- const visibility = VISIBILITY.includes(input.visibility) ? input.visibility : 'private';
+ if (input.audience !== undefined && !AUDIENCE.includes(input.audience)) throw new UploadError(422, 'upload.audience', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');
+ const audience = input.audience || (VISIBILITY.includes(input.visibility) ? audienceOf(input.visibility) : sharing ? await sharing.defaultAudience(who.requester, 'video') : 'public');
+ const visibility = visibilityOf(audience);
const t = String(input.contentType || '').split(';')[0].trim().toLowerCase();
const t = String(input.contentType || '').split(';')[0].trim().toLowerCase();
const kind = t === 'video/webm' || t === 'video/mp4' ? t : null;
const kind = t === 'video/webm' || t === 'video/mp4' ? t : null;
if (!kind) throw new UploadError(415, 'upload.type', 'This browser records in a format OpenVibe.Video does not take (WebM or MP4).');
if (!kind) throw new UploadError(415, 'upload.type', 'This browser records in a format OpenVibe.Video does not take (WebM or MP4).');
@@ -150,7 +157,7 @@ function createUploads({ s, media, config, log = console, events = null, sharing
@@ -150,7 +157,7 @@ function createUploads({ s, media, config, log = console, events = null, sharing
await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at, watch_id)
await s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, created_at, updated_at, watch_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, 0, 0, $9, $9, $10)`,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, 0, 0, $9, $9, $10)`,
[id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, now, watchId]);
[id, who.requester, who.username || null, title, description, visibility, Number(vod.data.id), kind, now, watchId]);
- if (sharing) await sharing.register(who.requester, 'video', id, visibility);
+ if (sharing) await sharing.register(who.requester, 'video', id, audience);
return await byId(id);
return await byId(id);
}
}
@@ -275,10 +282,21 @@ function createUploads({ s, media, config, log = console, events = null, sharing
@@ -275,10 +282,21 @@ function createUploads({ s, media, config, log = console, events = null, sharing
const patch = {};
const patch = {};
if (input.title !== undefined) { const t = cleanLine(input.title, 120); if (!t) throw new UploadError(422, 'upload.title_required', 'Give the video a title.'); patch.title = t; }
if (input.title !== undefined) { const t = cleanLine(input.title, 120); if (!t) throw new UploadError(422, 'upload.title_required', 'Give the video a title.'); patch.title = t; }
if (input.description !== undefined) patch.description = cleanText(input.description, 5000);
if (input.description !== undefined) patch.description = cleanText(input.description, 5000);
- if (input.visibility !== undefined) { if (!VISIBILITY.includes(input.visibility)) throw new UploadError(422, 'upload.visibility', 'Public, unlisted or private.'); patch.visibility = input.visibility; }
- if (!Object.keys(patch).length) return row;
- if (patch.title || patch.visibility) await media.vodUpdate(row.media_vod_id, { ...(patch.title ? { title: patch.title } : {}), ...(patch.visibility ? { visibility: patch.visibility } : {}) });
- return await maybePublished(await patchRow(row.id, patch));
+ if (input.visibility !== undefined && !VISIBILITY.includes(input.visibility)) throw new UploadError(422, 'upload.visibility', 'Public, unlisted or private.');
+ if (input.audience !== undefined && !AUDIENCE.includes(input.audience)) throw new UploadError(422, 'upload.audience', 'Choose Everyone, Anyone with the link, Specific people, or Only me.');
+ const audience = input.audience || (input.visibility !== undefined ? audienceOf(input.visibility) : null);
+ if (!Object.keys(patch).length && !audience) return row;
+ if (patch.title) await media.vodUpdate(row.media_vod_id, { title: patch.title });
+ const registered = audience && sharing ? await sharing.shareOf('video', row.id) : null;
+ if (audience && registered) await sharing.setAudience({ subject: row.owner.slice(5) }, 'video', row.id, audience);
+ else if (audience) {
+ // Imports and older rows can predate share.v1. Keep their visibility change working,
+ // then give them a share row for future edits and reads.
+ patch.visibility = visibilityOf(audience);
+ await media.vodUpdate(row.media_vod_id, { visibility: patch.visibility });
+ if (sharing) await sharing.register(row.owner, 'video', row.id, audience);
+ }
+ return await maybePublished(Object.keys(patch).length ? await patchRow(row.id, patch) : await byId(row.id));
}
}
async function remove(row) {
async function remove(row) {
@@ -76,6 +76,7 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -76,6 +76,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');
await t.ctx.sharing.setDefaultAudience(`user:${kim.subject}`, 'download', 'private');
+ await t.ctx.sharing.setDefaultAudience(`user:${kim.subject}`, 'picture', 'people');
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');
@@ -86,8 +87,9 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -86,8 +87,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', 'share-defaults.json', 'shares.json', 'uniform-sharing.json', 'usage.json', 'video_likes.json'], 'and the rest of the person\'s own data');
+ assert.deepStrictEqual(part.files.map((f) => f.name).sort(), ['content-share-defaults.json', '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.strictEqual(part.files.find((f) => f.name === 'share-defaults.json').content[0].audience, 'private');
+ assert.strictEqual(part.files.find((f) => f.name === 'content-share-defaults.json').content[0].audience, 'people');
assert.ok(part.files.find((f) => f.name === 'uniform-sharing.json').content.owned.some((s) => s.kind === 'download' && s.resource_id === file.id));
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');
@@ -116,6 +118,7 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -116,6 +118,7 @@ const DELETION = 'del_01JABCDEFGHJKMNPQRSTVWXYZ0';
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_share_defaults, 1, 'the saved share default is erased');
+ assert.strictEqual(confirmation.erased.mh_content_share_defaults, 1, 'the Pics 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,133 @@
@@ -0,0 +1,133 @@
+'use strict';
+const assert = require('assert');
+const http = require('http');
+const sharp = require('sharp');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video' } });
+ const owner = t.network.addUser('owner');
+ const other = t.network.addUser('other');
+ const reader = t.network.addUser('reader');
+ const same = { 'sec-fetch-site': 'same-origin' };
+ const requester = (user) => `user:${user.subject}`;
+ const share = (kind, id) => t.ctx.sharing.shareOf(kind, id);
+ const hostRequest = (host, path, user, { method = 'GET', json } = {}) => new Promise((resolve, reject) => {
+ const url = new URL(t.base + path);
+ const body = json === undefined ? null : Buffer.from(JSON.stringify(json));
+ const req = http.request({ hostname: url.hostname, port: url.port, path: url.pathname + url.search,
+ method, headers: { Host: host, ...(user ? { Cookie: t.signIn(user) } : {}),
+ ...(body ? { 'Content-Type': 'application/json', 'Content-Length': body.length, 'Sec-Fetch-Site': 'same-origin' } : {}) } }, (res) => {
+ const parts = [];
+ res.on('data', (part) => parts.push(part));
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(parts).toString('utf8') }));
+ });
+ req.on('error', reject);
+ if (body) req.write(body);
+ req.end();
+ });
+ const hostGet = (host, path, user) => hostRequest(host, path, user);
+ let sequence = 0;
+ const picture = async (user, audience) => {
+ const n = ++sequence;
+ const buffer = await sharp({ create: { width: 16, height: 16, channels: 3, background: { r: n, g: n * 7, b: 99 } } }).png().toBuffer();
+ const out = await t.ctx.pics.upload({ requester: requester(user) }, { buffer, name: `${n}.png` }, { audience });
+ assert.strictEqual(out.ok, true, JSON.stringify(out));
+ return out.image;
+ };
+ const video = async (user, audience) => await t.ctx.videoUploads.start({ requester: requester(user), username: 'owner' }, {
+ title: `Video ${++sequence}`, size: 12, contentType: 'video/mp4', name: 'clip.mp4', audience,
+ });
+ try {
+ await check('each kind saves a personal default and rejects an invalid audience', async () => {
+ for (const kind of ['picture', 'album', 'video', 'download']) {
+ const get = await t.get(`/api/v1/share-defaults/${kind}`, { as: owner });
+ assert.strictEqual(get.status, 200, get.text);
+ assert.strictEqual(get.json().audience, 'public');
+ }
+ assert.strictEqual((await t.get('/api/v1/share-defaults/picture', { as: other })).json().audience, 'public');
+ const invalid = await t.get('/api/v1/share-defaults/picture', { as: owner, method: 'PUT', json: { audience: 'everyone-ish' }, headers: same });
+ assert.strictEqual(invalid.status, 422);
+ for (const [kind, audience] of [['picture', 'private'], ['album', 'people'], ['video', 'link']]) {
+ const set = await t.get(`/api/v1/share-defaults/${kind}`, { as: owner, method: 'PUT', json: { audience }, headers: same });
+ assert.strictEqual(set.status, 200, set.text);
+ assert.strictEqual(set.json().audience, audience);
+ }
+ const videoHost = await hostGet('openvibe.video', '/api/v1/share-defaults/video', owner);
+ assert.strictEqual(videoHost.status, 200, videoHost.text);
+ assert.strictEqual(JSON.parse(videoHost.text).audience, 'link');
+ const picsHost = await hostRequest('openvibe.pics', '/api/v1/share-defaults/picture', owner,
+ { method: 'PUT', json: { audience: 'private' } });
+ assert.strictEqual(picsHost.status, 200, picsHost.text);
+ });
+
+ let ownPic, otherPic, ownAlbum, otherAlbum, ownVideo, otherVideo;
+ await check('new items use the saved choice, with a per-item override', async () => {
+ ownPic = await picture(owner);
+ otherPic = await picture(other);
+ assert.strictEqual((await share('picture', ownPic.id)).audience, 'private');
+ assert.strictEqual((await share('picture', otherPic.id)).audience, 'public');
+ const overridePic = await picture(owner, 'public');
+ assert.strictEqual((await share('picture', overridePic.id)).audience, 'public');
+ ownAlbum = (await t.ctx.pics.createAlbum(requester(owner), { title: 'Owned album' })).album;
+ otherAlbum = (await t.ctx.pics.createAlbum(requester(other), { title: 'Other album' })).album;
+ assert.strictEqual((await share('album', ownAlbum.id)).audience, 'people');
+ assert.strictEqual((await share('album', otherAlbum.id)).audience, 'public');
+ ownVideo = await video(owner);
+ otherVideo = await video(other);
+ assert.strictEqual((await share('video', ownVideo.id)).audience, 'link');
+ assert.strictEqual(ownVideo.visibility, 'unlisted');
+ assert.strictEqual((await share('video', otherVideo.id)).audience, 'public');
+ const overrideVideo = await video(owner, 'people');
+ assert.strictEqual((await share('video', overrideVideo.id)).audience, 'people');
+ assert.strictEqual(overrideVideo.visibility, 'private');
+ });
+
+ await check('apply to all changes only the person\'s own items and updates Media video visibility', async () => {
+ for (const [kind, audience] of [['picture', 'link'], ['album', 'private'], ['video', 'private']]) {
+ const set = await t.get(`/api/v1/share-defaults/${kind}`, { as: owner, method: 'PUT', json: { audience, apply: true }, headers: same });
+ assert.strictEqual(set.status, 200, set.text);
+ assert(set.json().changed >= 1);
+ }
+ assert.strictEqual((await share('picture', ownPic.id)).audience, 'link');
+ assert.strictEqual((await share('picture', otherPic.id)).audience, 'public');
+ assert.strictEqual((await share('album', ownAlbum.id)).audience, 'private');
+ assert.strictEqual((await share('album', otherAlbum.id)).audience, 'public');
+ assert.strictEqual((await share('video', ownVideo.id)).audience, 'private');
+ assert.strictEqual((await share('video', otherVideo.id)).audience, 'public');
+ assert.strictEqual((await t.ctx.videoUploads.byId(ownVideo.id)).visibility, 'private');
+ assert.strictEqual(t.media.vods.get(Number(ownVideo.media_vod_id)).visibility, 'private');
+ });
+
+ await check('Specific people can open a ready Video watch route but a stranger cannot', async () => {
+ const row = await video(owner, 'people');
+ await t.ctx.sharing.addMembers({ subject: owner.subject }, 'video', row.id, [{ subject: { type: 'user', id: reader.subject }, role: 'viewer' }]);
+ await t.ctx.s.db.exec("UPDATE video_uploads SET status = 'ready' WHERE id = $1", [row.id]);
+ const path = `/u/${row.id}`;
+ const named = await hostGet('openvibe.video', path, reader);
+ const stranger = await hostGet('openvibe.video', path, other);
+ assert([200, 301].includes(named.status), named.text.slice(0, 300));
+ assert.strictEqual(stranger.status, 404);
+ if (named.status === 301) {
+ const watch = await hostGet('openvibe.video', named.headers.location, reader);
+ assert.strictEqual(watch.status, 200, watch.text.slice(0, 300));
+ assert(watch.text.includes(`Video ${sequence}`));
+ assert(watch.text.includes(`data-kind="video" data-id="${row.id}"`), 'the watch page opens the uniform Share sheet');
+ assert.strictEqual((await hostGet('openvibe.video', named.headers.location, other)).status, 404);
+ }
+ });
+
+ await check('owner pages include the saved choices and apply controls', async () => {
+ const pics = await hostGet('openvibe.pics', '/mine', owner);
+ assert.strictEqual(pics.status, 200, pics.text.slice(0, 200));
+ assert(pics.text.includes('Apply to all my existing pictures'));
+ const albums = await hostGet('openvibe.pics', '/albums', owner);
+ assert.strictEqual(albums.status, 200, albums.text.slice(0, 200));
+ assert(albums.text.includes('Apply to all my existing albums'));
+ const videos = await hostGet('openvibe.video', '/me/videos', owner);
+ assert.strictEqual(videos.status, 200, videos.text.slice(0, 200));
+ assert(videos.text.includes('Apply to all my existing videos'));
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
* Sharing on MediaHub pictures and albums (design 2026-11-06, VISIBILITY-EVERYWHERE step 4;
* Sharing on MediaHub pictures and albums (design 2026-11-06, VISIBILITY-EVERYWHERE step 4;
- * openvibe-sdk/share). Unlisted by default (publishing is explicit),
+ * openvibe-sdk/share). Everyone by default (gallery publishing is explicit),
* Only me and Specific people narrow it; the share row decides the page, the file, the thumbnail
* Only me and Specific people narrow it; the share row decides the page, the file, the thumbnail
* and an album, and the gate never shows the picture.
* and an album, and the gate never shows the picture.
*/
*/
@@ -49,10 +49,10 @@ function hostClient(t) {
@@ -49,10 +49,10 @@ function hostClient(t) {
const putShare = (kind, id, body) => share(kind, id, { method: 'PUT', as: kate, json: body });
const putShare = (kind, id, body) => share(kind, id, { method: 'PUT', as: kate, json: body });
const row = async (id) => await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['mediahub', 'picture', id]);
const row = async (id) => await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['mediahub', 'picture', id]);
- await check('a new picture is registered for anyone with the link, owned by its uploader', async () => {
+ await check('a new picture is registered for Everyone, owned by its uploader', async () => {
const img = await upload();
const img = await upload();
const s = await row(img.id);
const s = await row(img.id);
- assert.strictEqual(s.audience, 'link');
+ assert.strictEqual(s.audience, 'public');
assert.deepStrictEqual(s.owner_subject, { type: 'user', id: kate.subject });
assert.deepStrictEqual(s.owner_subject, { type: 'user', id: kate.subject });
assert.strictEqual((await get(`/i/${img.slug}`)).status, 200);
assert.strictEqual((await get(`/i/${img.slug}`)).status, 200);
assert.strictEqual((await get(`/i/${img.slug}.png`)).status, 200);
assert.strictEqual((await get(`/i/${img.slug}.png`)).status, 200);
@@ -124,6 +124,10 @@ function hostClient(t) {
@@ -124,6 +124,10 @@ function hostClient(t) {
const hidden = await get(`/a/${album.slug}`);
const hidden = await get(`/a/${album.slug}`);
assert.strictEqual(hidden.status, 200);
assert.strictEqual(hidden.status, 200);
assert.ok(!hidden.text.includes(`/i/${img.slug}.png`), 'Only me is not in the album page');
assert.ok(!hidden.text.includes(`/i/${img.slug}.png`), 'Only me is not in the album page');
+ const hiddenApi = await get(`/api/v1/albums/${album.slug}`);
+ assert.strictEqual(hiddenApi.status, 200);
+ assert.strictEqual(hiddenApi.json().images.length, 0, 'the album API does not reveal a private picture or count');
+ assert.strictEqual((await get(`/api/v1/albums/${album.slug}`, { as: kate })).json().images.length, 1);
assert.strictEqual((await get(`/i/${img.slug}.png`, { as: kate })).status, 200);
assert.strictEqual((await get(`/i/${img.slug}.png`, { as: kate })).status, 200);
// The album's own audience carries its pictures.
// The album's own audience carries its pictures.
assert.strictEqual((await putShare('album', album.id, { audience: 'private' })).status, 200);
assert.strictEqual((await putShare('album', album.id, { audience: 'private' })).status, 200);
@@ -131,6 +135,32 @@ function hostClient(t) {
@@ -131,6 +135,32 @@ function hostClient(t) {
assert.strictEqual((await t.ctx.s.db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['mediahub', 'picture', img.id])).audience, 'private');
assert.strictEqual((await t.ctx.s.db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['mediahub', 'picture', img.id])).audience, 'private');
});
});
+ await check('an album member sees its People pictures in page, API and bytes; a stranger sees no album', async () => {
+ const img = await upload();
+ const made = await t.ctx.pics.createAlbum(`user:${kate.subject}`, { title: 'Family', audience: 'people' });
+ assert.strictEqual(made.ok, true);
+ await t.ctx.pics.addToAlbum(made.album, await picsStore.imageBySlug(t.ctx.s, img.slug));
+ assert.strictEqual((await row(img.id)).audience, 'people', 'new album pictures follow the album audience');
+ await putShare('album', made.album.id, { audience: 'people', members: [{ subject: { type: 'user', id: mate.subject }, role: 'viewer' }] });
+ assert.strictEqual((await get(`/a/${made.album.slug}`, { as: mate })).status, 200);
+ assert.strictEqual((await get(`/api/v1/albums/${made.album.slug}`, { as: mate })).json().images.length, 1);
+ assert.strictEqual((await get(`/i/${img.slug}.png`, { as: mate })).status, 200);
+ assert.strictEqual((await get(`/a/${made.album.slug}`, { as: sam })).status, 404);
+ assert.strictEqual((await get(`/api/v1/albums/${made.album.slug}`, { as: sam })).status, 404);
+ assert.strictEqual((await get(`/i/${img.slug}.png`, { as: sam })).status, 404);
+ });
+
+ await check('filing a private picture in a public album does not expose it', async () => {
+ const img = await upload();
+ assert.strictEqual((await putShare('picture', img.id, { audience: 'private' })).status, 200);
+ const made = await t.ctx.pics.createAlbum(`user:${kate.subject}`, { title: 'Open album', audience: 'public' });
+ assert.strictEqual(made.ok, true);
+ await t.ctx.pics.addToAlbum(made.album, await picsStore.imageBySlug(t.ctx.s, img.slug));
+ assert.strictEqual((await row(img.id)).audience, 'private');
+ assert.strictEqual((await get(`/api/v1/albums/${made.album.slug}`)).json().images.length, 0);
+ assert.strictEqual((await get(`/api/v1/albums/${made.album.slug}`, { as: kate })).json().images.length, 1);
+ });
+
await done();
await done();
await t.close();
await t.close();
})();
})();