openvibers/openvibe.mediahub Commits

Add sharing defaults for Pics albums and Video (#401)

OP OpenVibers committed 2 days ago · 9ecfeff247 · 19 files, +424 −89

* Add sharing defaults for Pics albums and Video

* Keep private pictures private when added to albums

* Keep legacy video updates working without a share row

Changes

ddiff --git a/migrations/0033_content_share_defaults.sql b/migrations/0033_content_share_defaults.sql
nnew file mode 100644
iindex 0000000..2a39e2c
---- /dev/null
++++ b/migrations/0033_content_share_defaults.sql
@@@ -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 $$;
ddiff --git a/public/js/pics.js b/public/js/pics.js
iindex a80d7da..9c14af0 100644
---- a/public/js/pics.js
++++ b/public/js/pics.js
@@@ -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 () {
ddiff --git a/public/js/video-upload.js b/public/js/video-upload.js
iindex 9f65088..ee309ea 100644
---- a/public/js/video-upload.js
++++ b/public/js/video-upload.js
@@@ -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();
ddiff --git a/server/account.js b/server/account.js
iindex 9ab57c6..b4a0d9a 100644
---- a/server/account.js
++++ b/server/account.js
@@@ -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' },
ddiff --git a/server/app.js b/server/app.js
iindex d0a94a7..e8282e1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 });
ddiff --git a/server/http/api.js b/server/http/api.js
iindex b13247a..e5cdcef 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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.',
});
});
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
iindex 372ea3c..99735bd 100644
---- a/server/http/openapi.js
++++ b/server/http/openapi.js
@@@ -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' },
} }),
ddiff --git a/server/pics/api.js b/server/pics/api.js
iindex a17d192..6fef41b 100644
---- a/server/pics/api.js
++++ b/server/pics/api.js
@@@ -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) => {
ddiff --git a/server/pics/gallery.js b/server/pics/gallery.js
iindex 1c0623c..33d7d3a 100644
---- a/server/pics/gallery.js
++++ b/server/pics/gallery.js
@@@ -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 };
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex b75cdca..8a0a15a 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -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" name
const 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 class
r.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 class
const share = await sharing.shareOf('album', album.id).catch(() => null);
if (share && !await sharing.canView(req.viewer, 'album', album.id).catch(() => false)) return notFound(req, res);
}
-- const rows = (await store.albumImages(s, album.id, { visibleOnly: !owner })).filter((i) => owner || i.audience === 'public' || i.audience === 'link');
++ 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="res
markdown: 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}/` }] },
],
ddiff --git a/server/pics/resumable.js b/server/pics/resumable.js
iindex 268a2f7..8380de9 100644
---- a/server/pics/resumable.js
++++ b/server/pics/resumable.js
@@@ -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]);
ddiff --git a/server/pics/service.js b/server/pics/service.js
iindex 0f5e804..8fd9fc4 100644
---- a/server/pics/service.js
++++ b/server/pics/service.js
@@@ -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) } : {}),
});
ddiff --git a/server/sharing.js b/server/sharing.js
iindex fa42516..3e330d1 100644
---- a/server/sharing.js
++++ b/server/sharing.js
@@@ -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 = globalT
const at = (kind, id) => refOf(kind, id);
async function defaultAudience(owner, kind) {
-- if (kind !== 'download') throw new Error('Unknown share kind');
-- const row = await db.maybe('SELECT audience FROM mh_share_defaults WHERE owner = $1 AND kind = $2', [owner, kind]);
++ 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)),
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 99fb2dd..a7e3306 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -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
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex 998e3ae..b198a34 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -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>` : ht
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);
-- 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>` : ht
const 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 thi
if (!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 thi
r.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 || {};
ddiff --git a/server/video/uploads.js b/server/video/uploads.js
iindex 76ae369..244b13b 100644
---- a/server/video/uploads.js
++++ b/server/video/uploads.js
@@@ -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, sharing
const 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, 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)
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, sharing
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 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, 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)
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, sharing
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.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) {
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 0e68324..7b072cd 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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');
ddiff --git a/test/content-share-defaults.test.js b/test/content-share-defaults.test.js
nnew file mode 100644
iindex 0000000..0b49609
---- /dev/null
++++ b/test/content-share-defaults.test.js
@@@ -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); });
ddiff --git a/test/pics-sharing.test.js b/test/pics-sharing.test.js
iindex ad8fcf8..3b29a1b 100644
---- a/test/pics-sharing.test.js
++++ b/test/pics-sharing.test.js
@@@ -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();
})();