openvibers/openvibe.mediahub Commits
Pics: a public gallery of the pictures people chose to publish (#347)
Changes
@@@ -532,3 +532,27 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [- openvibe-sdk: v0.123.0- openvibe-shared: v4.173.0<!-- versions:end -->++++### Public Pics gallery++++`GET /api/v1/gallery?sort=popular|new|rising&q=&tag=&cursor=&limit=30` lists only active,++explicitly published pictures whose sharing audience is Everyone. A new upload is unlisted: anyone with++the link can open it and nobody else, and no picture is opted in by migration 0028. The owner publishes using++`POST /api/v1/images/:slug/publication` with `{ "listed": true, "tags": ["landscape"] }`.++Setting `listed` false removes discovery without changing access; narrowing Share also clears publication.++`GET /api/v1/pictures/:id` respects the current audience, and authenticated readers can retrieve raster++bytes at `GET /api/v1/images/:slug/bytes`. Public likes are idempotent PUT/DELETE `/images/:slug/like`.++Views count one keyed address per day on public picture pages; private pictures expose no counts.++++Resumable uploads: POST `/api/v1/uploads` with `{size, content_type, name, title}`, PATCH `/uploads/:id`++with raw chunks up to 1 MiB and `Upload-Offset`, GET the same resource to resume, POST++`/uploads/:id/complete` to re-encode and save privately, or DELETE to cancel. Sessions expire after a day;++four unfinished uploads per person, bounded by the ordinary image size, quota and daily limits. Complete++is idempotent. Expired chunks are reclaimed when new sessions are created. All writes require a person;++session-cookie writes must be same-origin. `pics.picture@1` and `pics.gallery@1` describe the responses.++++Cross-site rollout: deploy the Community Pics discussion policy before enabling discussions; it checks++current public visibility on every read. Studio's image-tool adapter requires `STUDIO_IMAGE_WORKER_URL`++and returns an explicit unavailable state until a worker is configured. No image worker was invoked in++this change. Cam already posts captured pictures to the existing upload endpoint. Shared `ov-upload`++is not in the pinned Shared release yet; Pics retains its existing drop/paste/choose uploader.nnew file mode 100644@@@ -0,0 +1,26 @@++-- phase: expand++-- Discovery is separate from access: no existing picture is opted in by this migration.++ALTER TABLE pics_images ADD COLUMN IF NOT EXISTS listed boolean NOT NULL DEFAULT false;++ALTER TABLE pics_images ADD COLUMN IF NOT EXISTS tags text NOT NULL DEFAULT '[]';++ALTER TABLE pics_images ADD COLUMN IF NOT EXISTS author_name text NOT NULL DEFAULT '';++ALTER TABLE pics_images ADD COLUMN IF NOT EXISTS published_at timestamptz;++CREATE INDEX IF NOT EXISTS pics_gallery_new ON pics_images (published_at DESC, id DESC) WHERE listed = true AND state = 'active' AND audience = 'public';++CREATE TABLE IF NOT EXISTS pics_likes (++ image_id text NOT NULL REFERENCES pics_images(id) ON DELETE CASCADE,++ actor text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (image_id, actor)++);++CREATE TABLE IF NOT EXISTS pics_views (++ image_id text NOT NULL REFERENCES pics_images(id) ON DELETE CASCADE,++ visitor text NOT NULL, day text NOT NULL, PRIMARY KEY (image_id, visitor, day)++);++CREATE TABLE IF NOT EXISTS pics_uploads (++ id text PRIMARY KEY, owner text NOT NULL, size integer NOT NULL CHECK (size > 0),++ content_type text NOT NULL, name text NOT NULL, title text NOT NULL,++ offset_bytes integer NOT NULL DEFAULT 0, expires_at timestamptz NOT NULL,++ image_id text REFERENCES pics_images(id) ON DELETE SET NULL++);++CREATE TABLE IF NOT EXISTS pics_upload_chunks (++ upload_id text NOT NULL REFERENCES pics_uploads(id) ON DELETE CASCADE,++ offset_bytes integer NOT NULL, bytes bytea NOT NULL,++ PRIMARY KEY (upload_id, offset_bytes)++);@@@ -211,3 +211,64 @@.ovp-tile-cover > .ovp-tile { height: 100%; border-radius: inherit; }.ovp-title + .ovk-labels { margin: -4px 0 12px; }@media (max-width: 640px) { .ovp-tile-cover { border-radius: 8px; } }++++/* The public gallery (owner's design pass, 11-09): pictures first. A masonry of whole pictures at their own shape, each++ with one quiet caption under it: the title, who and the counts on one line, tags as small chips. Above it, one search++ field (its glyph and button inside) beside a segmented Popular / New / Rising. */++.ovp-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }++.ovp-gallery-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 18px 0 16px; }++.ovp-gallery-search { flex: 1 1 320px; display: flex; align-items: center; gap: 8px; min-width: 0; min-height: 46px; padding: 0 5px 0 14px; border: 1px solid var(--ovp-line); border-radius: 999px; background: var(--ovp-card); }++.ovp-gallery-search:focus-within { border-color: var(--ovp-pink); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovp-pink) 22%, transparent); }++.ovp-gallery-search .ovp-gc-ic { color: var(--ovp-faint); }++.ovp-gallery-search input { flex: 1; min-width: 0; min-height: 44px; padding: 0; border: 0; outline: 0; background: transparent; color: var(--text-primary, #e6edf7); font: inherit; }++.ovp-gallery-search button { flex: none; min-height: 36px; padding: 0 16px; border: 0; border-radius: 999px; background: var(--ovp-pink); color: var(--ovp-on-pink); font: inherit; font-weight: 700; cursor: pointer; }++.ovp-gallery-tabs { display: inline-flex; flex: none; gap: 2px; padding: 3px; border: 1px solid var(--ovp-line); border-radius: 999px; background: var(--ovp-card); }++.ovp-gallery-tabs a { display: inline-flex; align-items: center; min-height: 38px; padding: 0 16px; border-radius: 999px; color: var(--ovp-dim); font-weight: 600; text-decoration: none; }++.ovp-gallery-tabs a:hover { color: var(--text-primary, #e6edf7); }++.ovp-gallery-tabs [aria-current] { background: color-mix(in srgb, var(--ovp-pink) 18%, transparent); color: var(--text-primary, #e6edf7); }++.ovp-gallery-tagged { margin: -4px 0 14px; color: var(--ovp-dim); }++.ovp-gallery-tagged a { margin-left: 8px; }++.ovp-gallery { columns: 4 220px; column-gap: 16px; }++.ovp-gallery-card { break-inside: avoid; margin: 0 0 18px; }++.ovp-gc-pic { display: block; overflow: hidden; border-radius: 14px; background: var(--ovp-card); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ovp-line) 70%, transparent); }++.ovp-gc-pic img { display: block; width: 100%; height: auto; transition: transform .35s ease, filter .35s ease; }++.ovp-gallery-card:hover .ovp-gc-pic img { transform: scale(1.03); filter: brightness(1.06); }++@media (prefers-reduced-motion: reduce) { .ovp-gc-pic img { transition: none; } .ovp-gallery-card:hover .ovp-gc-pic img { transform: none; } }++.ovp-gc-body { padding: 9px 4px 0; }++.ovp-gc-title { margin: 0; font-size: 15px; line-height: 1.3; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovp-gc-title a { color: var(--text-primary, #e6edf7); text-decoration: none; }++.ovp-gc-title a:hover { text-decoration: underline; text-underline-offset: 3px; }++.ovp-gc-title a::after { content: none; }++.ovp-gc-meta { display: flex; align-items: center; gap: 12px; margin: 4px 0 0; font-size: 13px; color: var(--ovp-dim); min-width: 0; }++.ovp-gc-by { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovp-gc-n { flex: none; display: inline-flex; align-items: center; gap: 4px; font-variant-numeric: tabular-nums; }++.ovp-gc-ic { display: inline-grid; place-items: center; flex: none; }++.ovp-gc-ic svg { display: block; }++.ovp-gc-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; }++.ovp-tag { display: inline-flex; align-items: center; min-height: 26px; padding: 0 10px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--ovp-dim); font-size: 12.5px; font-weight: 600; text-decoration: none; }++.ovp-tag:hover { background: color-mix(in srgb, var(--ovp-pink) 18%, transparent); color: var(--text-primary, #e6edf7); }++.ovp-gallery-empty { padding: 30px 0; }++.ovp-drop--compact .ovp-drop-icon, .ovp-drop--compact .ovp-drop-fields { display: none; }++.ovp-drop--compact .ovp-drop-inner { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 14px; }++.ovp-drop--compact .ovp-drop-title { margin: 0; font-size: 16px; }++.ovp-drop--compact .ovp-drop-hint { flex-basis: 100%; font-size: 13px; }++@media (max-width: 600px) {++ .ovp-gallery { columns: 2; column-gap: 10px; }++ .ovp-gallery-card { margin-bottom: 14px; }++ .ovp-gc-pic { border-radius: 12px; }++ .ovp-gc-title { font-size: 14px; }++ .ovp-gc-meta { gap: 8px; font-size: 12px; }++ .ovp-gc-meta .ovp-gc-n:last-child { display: none; }++ .ovp-gallery-bar { gap: 10px; }++ .ovp-gallery-search { flex-basis: 100%; }++ .ovp-gallery-tabs { width: 100%; }++ .ovp-gallery-tabs a { flex: 1; justify-content: center; min-height: 40px; }++ .ovp-tag { min-height: 32px; }++}++++.ovp-drop--compact .ovp-drop-inner { flex-direction: row; text-align: left; }++.ovp-drop--compact .ovp-drop-inner p { margin: 0; }++.ovp-drop--signin.ovp-drop--compact .ovp-drop-title { display: none; }++.ovp-drop--signin.ovp-drop--compact .ovp-drop-hint { display: block; flex: 1; }++.ovp-gallery-search input { background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: 8px; min-height: 44px; padding: 8px 12px; }++.ovp-btn { min-height: 44px; }nnew file mode 100644@@@ -0,0 +1,55 @@++/* Public gallery: progressive loading and explicit, authenticated likes. */++(function () {++ 'use strict';++ var next = document.querySelector('[data-gallery-next]'), grid = document.querySelector('[data-gallery]'), busy = false;++ async function more(event) {++ if (event) event.preventDefault();++ if (busy || !next) return;++ busy = true;++ var status = document.querySelector('[data-gallery-status]');++ try {++ var response = await fetch(next.href, { credentials: 'same-origin' });++ if (!response.ok) throw new Error('Could not load pictures. Use More pictures to try again.');++ var doc = new DOMParser().parseFromString(await response.text(), 'text/html');++ doc.querySelectorAll('[data-gallery] > article').forEach(function (card) { grid.appendChild(document.importNode(card, true)); });++ var link = doc.querySelector('[data-gallery-next]');++ if (link) next.href = link.href; else { next.remove(); next = null; }++ status.textContent = 'More pictures loaded.';++ } catch (error) { status.textContent = error.message; }++ finally { busy = false; }++ }++ if (next) {++ next.addEventListener('click', more);++ if ('IntersectionObserver' in window) new IntersectionObserver(function (entries) { if (entries[0].isIntersecting) more(); }, { rootMargin: '300px' }).observe(next);++ }++ var discussion = document.querySelector('[data-comments]');++ if (discussion) discussion.addEventListener('click', async function () {++ var box = document.querySelector('[data-comments-list]'); discussion.disabled = true;++ try {++ var response = await fetch('https://openvibe.community/api/v1/comments/threads/resolve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref: { service: 'pics', type: 'picture', id: discussion.dataset.comments } }) });++ if (!response.ok) throw new Error('Discussion is unavailable. Try again later.');++ var result = await response.json(), thread = result.thread;++ if (!thread || !/^cth_[A-Za-z0-9_-]+$/.test(thread.id)) throw new Error('Discussion is unavailable.');++ var listing = await fetch('https://openvibe.community/api/v1/comments/threads/' + encodeURIComponent(thread.id));++ if (!listing.ok) throw new Error('Comments could not be loaded.');++ var data = await listing.json(); box.replaceChildren();++ (data.comments || []).forEach(function (comment) { var p = document.createElement('p'); p.textContent = comment.message; box.appendChild(p); });++ var link = document.createElement('a'); link.href = 'https://openvibe.community/c/' + encodeURIComponent(thread.id); link.textContent = 'Join the discussion on Community'; box.appendChild(link);++ } catch (error) { box.textContent = error.message; }++ finally { discussion.disabled = false; }++ });++ var like = document.querySelector('[data-like]');++ if (like) like.addEventListener('click', async function () {++ like.disabled = true;++ var status = document.querySelector('[data-like-status]');++ try {++ var removing = like.getAttribute('aria-pressed') === 'true';++ var response = await fetch('/api/v1/images/' + encodeURIComponent(like.dataset.like) + '/like', { method: removing ? 'DELETE' : 'PUT', credentials: 'same-origin' });++ if (!response.ok) throw new Error(response.status === 401 ? 'Sign in to like this picture.' : 'Could not update your like. Try again.');++ var data = await response.json();++ document.querySelector('[data-like-count]').textContent = data.likes;++ like.setAttribute('aria-pressed', String(!removing)); like.textContent = removing ? 'Like' : 'Liked'; status.textContent = '';++ } catch (error) { status.textContent = error.message; }++ finally { like.disabled = false; }++ });++})();@@@ -46,6 +46,9 @@ const TABLES = [{ table: 'pics_albums', subject: 'owner', value: AS_REQUESTER, file: 'pics_albums.json' },{ table: 'pics_images', subject: 'owner', value: AS_REQUESTER, file: 'pics_images.json' },{ table: 'pics_usage', subject: 'uploader', value: AS_REQUESTER, file: 'pics_usage.json' },++ // A Pics resumable upload that never finished (migrations/0028_pics_gallery.sql) is this service's own state,++ // like mh_uploads: erased with the account, not part of the export. The chunks cascade from it.++ { table: 'pics_uploads', subject: 'owner', value: AS_REQUESTER, file: null },// OpenVibe.Video uploads (migrations/0008_video_uploads.sql): the reports a person filed, then their videos (other// people's reports on them cascade). The bytes are Media VODs, deleted with extraErase.{ table: 'video_upload_reports', subject: 'reporter', value: AS_REQUESTER, file: 'video_upload_reports.json' },@@@ -138,7 +138,7 @@ async function createApp(opts = {}) {mediaSrc: ["'self'", 'blob:', ...MEDIA_ORIGINS],// The watch page counts a view on OpenVibe.Live with a beacon (Live's /api/views).// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).-- connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events', ...MEDIA_ORIGINS, liveOrigin],++ connectSrc: ["'self'", 'https://openvibe.community', 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events', ...MEDIA_ORIGINS, liveOrigin],frameSrc: ["'self'", 'https://openvibe.network'],frameAncestors: ["'none'"],objectSrc: ["'none'"],@@@ -29,6 +29,7 @@ const { asyncRouter } = require('../http/router');const { sameOrigin } = require('../http/principal');const store = require('./store');const contentLabels = require('../labels');++const gallery = require('./gallery');function createPicsApi(ctx) {const { config, s, principal, limits } = ctx;@@@ -64,6 +65,8 @@ function createPicsApi(ctx) {if (!row) { problem(req, res, 404, 'pics.not_found', 'No such picture.'); return null; }if (row.state === 'removed') { problem(req, res, 410, 'pics.taken_down', 'This picture was taken down.'); return null; }if (!pics.canSee(row, whoOf(req))) { problem(req, res, 404, 'pics.not_found', 'No such picture.'); return null; }++ const viewer = req.principal.kind === 'user' ? { kind: 'user', subject: req.principal.requester.slice(5) } : req.viewer;++ if (!await ctx.sharing.canView(viewer, 'picture', row.id)) { problem(req, res, 404, 'pics.not_found', 'No such picture.'); return null; }return row;}async function loadOwn(req, res) {@@@ -73,6 +76,41 @@ function createPicsApi(ctx) {return row;}++ r.use('/uploads', require('./resumable').resumable(ctx, { person, problem, whoOf }));++++ r.get('/gallery', async (req, res) => {++ const result = await gallery.browse(s, req.query);++ return res.json({ pictures: result.rows.map((row) => gallery.wire(pics, row)), next: result.next, sort: result.sort });++ });++ r.get('/pictures/:id', async (req, res) => {++ const row = await store.imageById(s, String(req.params.id));++ const viewer = req.principal.kind === 'user' ? { kind: 'user', subject: req.principal.requester.slice(5) } : req.viewer;++ if (!row || !pics.canSee(row, whoOf(req)) || !await ctx.sharing.canView(viewer, 'picture', row.id)) return problem(req, res, 404, 'pics.not_found', 'No such picture.');++ const extra = gallery.isPublic(row) ? await gallery.counts(s, row.id) : {};++ return res.json({ ...pics.toWire(row, { owner: pics.isOwner(row, whoOf(req)) }), ...extra });++ });++ r.post('/images/:slug/publication', person, express.json({ limit: '8kb' }), limits.budget('pics.image.write'), async (req, res) => {++ const row = await loadOwn(req, res);++ if (!row) return;++ const viewer = { kind: 'user', subject: req.principal.requester.slice(5), username: req.principal.username };++ const result = await gallery.publish(ctx, row, viewer, req.body || {});++ if (result.error) return problem(req, res, 422, 'pics.publication_invalid', result.error);++ return res.json(pics.toWire(await store.imageById(s, row.id), { owner: true }));++ });++ r.put('/images/:slug/like', person, limits.budget('pics.image.write'), async (req, res) => {++ const row = await load(req, res);++ if (!row) return;++ if (!gallery.isPublic(row)) return problem(req, res, 404, 'pics.not_found', 'No public picture.');++ await s.db.query(`INSERT INTO pics_likes (image_id, actor) SELECT i.id, $2 FROM pics_images i WHERE i.id = $1 AND ${gallery.PUBLIC} ON CONFLICT DO NOTHING`, [row.id, whoOf(req).requester]);++ return res.json(await gallery.counts(s, row.id));++ });++ r.delete('/images/:slug/like', person, limits.budget('pics.image.write'), async (req, res) => {++ const row = await load(req, res);++ if (!row || !gallery.isPublic(row)) return row ? problem(req, res, 404, 'pics.not_found', 'No public picture.') : undefined;++ await s.db.query('DELETE FROM pics_likes WHERE image_id = $1 AND actor = $2', [row.id, whoOf(req).requester]);++ return res.json(await gallery.counts(s, row.id));++ });++// ── Upload: the raw picture is the body ─────────────────const rawBody = express.raw({ type: () => true, limit: Math.max(cfg.maxImageBytes, cfg.anonymousMaxImageBytes) + 1 });r.post('/images', anyone, limits.budget('pics.image.upload'), (req, res, next) => rawBody(req, res, (err) => {@@@ -92,10 +130,18 @@ function createPicsApi(ctx) {r.use(express.json({ limit: '64kb' }));++ r.get('/images/:slug/bytes', person, async (req, res) => {++ const row = await load(req, res);++ if (!row) return;++ const got = await ctx.media.bytes(row.object_id);++ if (!got.ok) return problem(req, res, 502, 'pics.storage', 'Picture storage is unavailable.');++ res.set({ 'Content-Type': row.mime_type, 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox", 'Cache-Control': 'private, no-store' });++ return res.send(Buffer.from(await got.res.arrayBuffer()));++ });++r.get('/images/:slug', async (req, res) => {const row = await load(req, res);if (!row) return undefined;-- if (ctx.sharing && await ctx.sharing.shareOf('picture', row.id).catch(() => null) && !await ctx.sharing.canView(req.viewer, 'picture', row.id).catch(() => false)) return problem(req, res, 404, 'pics.image_not_found', 'No such picture.');return res.json(pics.toWire(row, { owner: pics.isOwner(row, whoOf(req)) }));});nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++/**++ * The public gallery: what OpenVibe.Pics shows when a picture's owner publishes it.++ *++ * 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++ * narrows it (server/sharing.js clears the flag) or a moderator hides it.++ */++const { html, raw } = require('../render/html');++const glyphs = require('openvibe-shared/glyphs');++const labelsUi = require('../render/labels-ui');++const PUBLIC = "i.listed = true AND i.state = 'active' AND i.audience = 'public' AND EXISTS (SELECT 1 FROM shares sh WHERE sh.service = 'mediahub' AND sh.kind = 'picture' AND sh.resource_id = i.id AND sh.audience = 'public')";++const isPublic = (row) => Boolean(row.listed && row.state === 'active' && row.audience === 'public');++const tags = (row) => { try { return JSON.parse(row.tags || '[]'); } catch { return []; } };++function parseTags(value) {++ if (!Array.isArray(value) || value.length > 10 || value.some((t) => typeof t !== 'string' || !/^[\p{L}\p{N}_-]{1,32}$/u.test(t))) return null;++ return [...new Set(value.map((t) => t.toLowerCase()))];++}++/** The heading icon the showcase kit puts beside every section title (openvibe-shared/showcase keeps its own copy). */++function headIcon(name) {++ const key = glyphs.NAMES.includes(name) ? name : (glyphs.fromFa ? glyphs.fromFa(name) : null);++ return key && glyphs.path(key) ? `<span class="sc-h-ic" aria-hidden="true">${glyphs.svg(key, { size: 18 })}</span>` : '';++}++function options(query = {}) {++ return { sort: ['popular', 'new', 'rising'].includes(query.sort) ? query.sort : 'popular',++ q: String(query.q || '').trim().slice(0, 120), tag: String(query.tag || '').trim().toLowerCase().slice(0, 32),++ offset: Math.min(10000, Math.max(0, parseInt(query.cursor, 10) || 0)), limit: Math.min(30, Math.max(1, parseInt(query.limit, 10) || 30)) };++}++async function browse(s, query) {++ const o = options(query);++ const order = o.sort === 'new' ? 'published_at DESC, id DESC' : o.sort === 'rising' ? 'recent_likes DESC, published_at DESC, id DESC' : 'likes DESC, views DESC, published_at DESC, id DESC';++ const rows = await s.db.many(`SELECT * FROM (SELECT i.*,++ (SELECT count(*)::int FROM pics_likes l WHERE l.image_id = i.id) AS likes,++ (SELECT count(*)::int FROM pics_views v WHERE v.image_id = i.id) AS views,++ (SELECT count(*)::int FROM pics_likes l WHERE l.image_id = i.id AND l.created_at > $5::timestamptz) AS recent_likes++ FROM pics_images i WHERE ${PUBLIC}++ AND ($1 = '' OR position(lower($1) in lower(i.title || ' ' || i.description || ' ' || i.tags)) > 0)++ AND ($2 = '' OR i.tags::jsonb @> jsonb_build_array($2::text))) g++ ORDER BY ${order} LIMIT $3 OFFSET $4`, [o.q, o.tag, o.limit + 1, o.offset, new Date(s.now() - 86400000).toISOString()]);++ return { rows: rows.slice(0, o.limit), next: rows.length > o.limit ? String(o.offset + o.limit) : null, ...o };++}++async function counts(s, id) {++ return s.db.maybe(`SELECT (SELECT count(*)::int FROM pics_likes WHERE image_id = $1) AS likes,++ (SELECT count(*)::int FROM pics_views WHERE image_id = $1) AS views`, [id]);++}++async function publish(ctx, row, viewer, input) {++ if (typeof input.listed !== 'boolean') return { error: 'Choose whether to publish this picture.' };++ const parsed = input.tags === undefined ? tags(row) : parseTags(input.tags);++ if (!parsed) return { error: 'Use up to ten tags of 1–32 letters, numbers, underscores or hyphens.' };++ if (input.listed && row.state !== 'active') return { error: 'Only an active picture can be published.' };++ await ctx.s.tx(async () => {++ if (input.listed) await ctx.sharing.setAudience(viewer, 'picture', row.id, 'public');++ await ctx.s.db.query(`UPDATE pics_images SET listed = $2, tags = $3, author_name = $4,++ published_at = CASE WHEN $2 THEN coalesce(published_at, $5::timestamptz) ELSE published_at END WHERE id = $1`,++ [row.id, input.listed, JSON.stringify(parsed), String(viewer.displayName || viewer.username || 'OpenVibe member').slice(0, 100), ctx.s.iso()]);++ });++ return { ok: true };++}++const wire = (pics, row) => ({ ...pics.toWire(row), author: row.author_name || 'OpenVibe member', tags: tags(row), likes: Number(row.likes || 0), views: Number(row.views || 0), published_at: row.published_at });++/** 1234 → "1.2K": counts on a card are read at a glance; the exact number is in the title and the screen-reader text. */++const compact = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}K` : String(n)).replace('.0', '');++const glyph = (name, size = 14) => raw(`<span class="ovp-gc-ic" aria-hidden="true">${glyphs.svg(name, { size })}</span>`);++const count = (n, one, many, name) => html`<span class="ovp-gc-n" title="${n} ${n === 1 ? one : many}">${glyph(name)}<span aria-hidden="true">${compact(n)}</span><span class="ovp-sr">${n} ${n === 1 ? one : many}</span></span>`;++// The picture is a link too (a second way in, out of the tab order); the title is the card's one link a keyboard meets.++const card = (pics, row) => html`<article class="ovp-gallery-card"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner })}><a class="ovp-gc-pic" href="/i/${row.slug}" tabindex="-1"><img src="/i/${row.slug}/thumb.webp" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" loading="lazy" decoding="async"></a>++<div class="ovp-gc-body"><h3 class="ovp-gc-title"><a href="/i/${row.slug}">${row.title || 'Untitled picture'}</a></h3>++<p class="ovp-gc-meta"><span class="ovp-gc-by">${row.author_name || 'OpenVibe member'}</span>${count(Number(row.views || 0), 'view', 'views', 'eye')}${count(Number(row.likes || 0), 'like', 'likes', 'heart')}</p>++${tags(row).length ? html`<p class="ovp-gc-tags">${tags(row).map((t) => html`<a class="ovp-tag" href="/?tag=${encodeURIComponent(t)}">#${t}</a>`)}</p>` : ''}</div></article>`;++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>++<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 ? html`<p class="ovp-gallery-empty">${result.q || result.tag ? 'No public pictures match yet. Try another search.' : 'The gallery is ready for its first public picture. Upload one, then choose Publish to gallery on its page.'}</p>` : ''}++${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 };@@@ -104,7 +104,7 @@ ${render(o.body)}}// The site's own places in the navbar: upload, your pictures and your albums (those two ask you to sign in).--const NAV_LINKS = [{ label: 'Upload', href: '/' }, { label: 'Your pictures', href: '/mine' }, { label: 'Albums', href: '/albums' }];++const NAV_LINKS = [{ label: 'Explore', href: '/' }, { label: 'Your pictures', href: '/mine' }, { label: 'Albums', href: '/albums' }];// A signed-out copy is never reused without asking: Cloudflare drops `Vary` (it keeps only Accept-Encoding), so a// browser keyed the guest copy by its URL alone and, for five minutes after a quiet sign-in, showed the signed-out page@@@ -40,6 +40,8 @@ const store = require('./store');const { createPicsApi } = require('./api');const { createPicVariants } = require('./variants');const kit = require('openvibe-shared/kit');++const gallery = require('./gallery');++const showcase = require('openvibe-shared/showcase');const { PATHS: POLICY_PATHS, policyPage } = require('./policy');const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');@@@ -234,17 +236,26 @@ 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: 'Only people you give the link to. Pictures are never listed, ranked or shown to search engines, and you can delete one at any time.' },++ { 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: '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.` },{ q: 'What is not allowed?', a: 'Anything illegal, sexual content shared without consent, harassment, other people\'s private information, and spam. Every picture has a Report form, a child-safety report hides it at once, and copyright owners can use the DMCA page.' },];r.get('/', async (req, res) => {++ 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 description = 'Upload a picture and get a link to share or embed anywhere: albums, any size or format, and location and camera details removed from every picture.';++ const description = 'Upload a picture and get a link to share or embed anywhere: a gallery of what people chose to publish, albums, any size or format, and location and camera details removed from every picture.';const faq = homeFaq();++ // The curl sample keeps its line breaks: a backslash and a newline, so it can be copied and run as it is.++ const curl = ['curl -X POST ' + base + '/api/v1/images \\', ' -H "Authorization: Bearer $OPENVIBE_TOKEN" \\', ' -H "Content-Type: image/jpeg" \\', ' --data-binary @photo.jpg'].join('\n');++ const limitColumns = ['Signed in', ...(cfg.anonymous ? ['Without an account'] : [])];++ const limitRows = [++ { label: 'Largest picture', values: [sizeText(cfg.maxImageBytes), ...(cfg.anonymous ? [sizeText(cfg.anonymousMaxImageBytes)] : [])] },++ { label: 'Pictures a day', values: [String(cfg.imagesPerDay), ...(cfg.anonymous ? [String(cfg.anonymousPerDay)] : [])] },++ { label: 'Storage', values: [sizeText(cfg.quotaBytes), ...(cfg.anonymous ? ['Kept ' + cfg.anonymousIdleDays + ' days after the last view'] : [])] },++ ];return page(req, res, {index: true,title: `${SITE_NAME}: share and embed pictures with a link`,@@@ -252,62 +263,76 @@ function createPicsRoutes(ctx) {jsonLd: [seo.jsonLd.website({ name: SITE_NAME, url: base, description }),seo.jsonLd.organization(),-- seo.jsonLd.softwareApp({ name: SITE_NAME, url: base, description, category: 'MultimediaApplication', keywords: 'image hosting, picture sharing, embed images, resize images, convert to WebP, alt text' }),++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: base, description, category: 'MultimediaApplication', keywords: 'image hosting, picture sharing, public picture gallery, embed images, resize images, convert to WebP, alt text' }),seo.jsonLd.webPage({ name: SITE_NAME, url: `${base}/`, description, siteUrl: base }),seo.jsonLd.faq(faq),],-- scripts: canUpload ? ['js/pics.js'] : [],-- body: html`<header class="ovp-hero"><p class="ovp-eyebrow">${SITE_NAME}</p>--<h1>Share pictures. <span class="ovp-accent">Fast, open, yours.</span></h1>--<p class="ovp-lede">Drop a picture, get a link to share or embed anywhere: on a forum, in a chat, on your site. No feed to scroll, no counts to chase, and nothing about you hidden in the file.</p></header>--${canUpload ? uploadForm(req) : html`<section class="ovp-drop 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>`}++ styles: ['kit.css', 'showcase.css'],++ scripts: ['js/pics.js', 'js/pics-gallery.js'],++ body: html`${raw(showcase.hero({++ title: 'Share pictures.', accent: 'Discover something new.',++ 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>`}++${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>--<section class="ovp-section" aria-labelledby="h-how"><h2 id="h-how">How it works</h2><ol class="ovp-steps">--<li><h3>Drop a picture</h3><p>Choose, drag or paste ${FORMAT_NAMES}, up to ${sizeText(cfg.maxImageBytes)}. Several at once works too.</p></li>--<li><h3>Get your links</h3><p>A page to share, a direct link, and Markdown, BBCode and HTML ready to copy.</p></li>--<li><h3>Use it anywhere</h3><p>Post it in a forum or a chat, embed it on your site, or put it in an album. Delete it whenever you like.</p></li>--</ol></section>--<section class="ovp-section ovp-features" aria-label="What you get">--<div class="ovp-feature"><h3>Links that work everywhere</h3><p>A page to share, a direct link for <code><img></code>, and Markdown, BBCode and HTML ready to copy.</p></div>--<div class="ovp-feature"><h3>Unlisted by default</h3><p>Only people with the link see a picture. Nothing is listed, ranked or indexed.</p></div>--<div class="ovp-feature"><h3>Clean files</h3><p>Every picture is re-encoded, and its GPS position, camera serial and other hidden details are removed before anyone sees it.</p></div>--<div class="ovp-feature"><h3>Albums</h3><p>Put pictures together and share the album with one link.</p></div>--<div class="ovp-feature"><h3>Any size, any format</h3><p>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.</p></div>--<div class="ovp-feature"><h3>Alt text</h3><p>Say what a picture shows for people who cannot see it, or let AI draft it when you ask. It goes into every embed snippet.</p></div>--</section>--<section class="ovp-section" aria-labelledby="h-links"><h2 id="h-links">The links a picture gets</h2>--<p class="ovp-section-lede">Each picture has a short name of eight letters and digits. These are the links it answers at:</p>--<dl class="ovp-links">--<div><dt>Its page</dt><dd><code>${base}/i/<name></code></dd></div>--<div><dt>The picture itself, in the format you uploaded</dt><dd><code>${base}/i/<name>.jpg</code></dd></div>--<div><dt>Another width or format</dt><dd><code>${base}/i/<name>/w/640.avif</code></dd></div>--<div><dt>Markdown, with your alt text</dt><dd><code></code></dd></div>--</dl></section>--<section class="ovp-section" aria-labelledby="h-api"><h2 id="h-api">Upload from your own app</h2>--<p class="ovp-section-lede">The API takes the picture as the request body, with an OpenVibe sign-in token, and answers with its links.</p>--<pre class="ovp-code" tabindex="0" aria-label="Upload with curl"><code>curl -X POST ${base}/api/v1/images \\-- -H "Authorization: Bearer $OPENVIBE_TOKEN" \\-- -H "Content-Type: image/jpeg" \\-- --data-binary @photo.jpg</code></pre></section>--<section class="ovp-section" aria-labelledby="h-limits"><h2 id="h-limits">What a free account gets</h2>--<div class="ovp-table-wrap" tabindex="0" role="region" aria-labelledby="h-limits"><table class="ovp-size-table ovp-limits"><thead><tr><th scope="col">Limit</th><th scope="col">Signed in</th>${cfg.anonymous ? html`<th scope="col">Without an account</th>` : ''}</tr></thead><tbody>--<tr><th scope="row">Largest picture</th><td>${sizeText(cfg.maxImageBytes)}</td>${cfg.anonymous ? html`<td>${sizeText(cfg.anonymousMaxImageBytes)}</td>` : ''}</tr>--<tr><th scope="row">Pictures a day</th><td>${cfg.imagesPerDay}</td>${cfg.anonymous ? html`<td>${cfg.anonymousPerDay}</td>` : ''}</tr>--<tr><th scope="row">Storage</th><td>${sizeText(cfg.quotaBytes)}</td>${cfg.anonymous ? html`<td>Kept ${cfg.anonymousIdleDays} days after the last view</td>` : ''}</tr>--</tbody></table></div></section>--<section class="ovp-section" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2><div class="faq">${faq.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</div></section>--<section class="ovp-section" aria-labelledby="h-policies"><h2 id="h-policies">What we promise, in writing</h2><div class="ovp-features">--<div class="ovp-feature"><h3><a href="/exif">What happens to your picture</a></h3><p>The metadata — the GPS position, the camera, the time it was taken — is taken out by us before anyone sees the picture, and the file you uploaded is not kept.</p></div>--<div class="ovp-feature"><h3><a href="/content-policy">What you may put here</a></h3><p>What is not allowed, how a rule is enforced, and what this site does not do: no feed, no counts, no adverts.</p></div>--<div class="ovp-feature"><h3><a href="/ai-training">Pictures and AI</a></h3><p>We don't train AI on your content. If that ever changes, it'll be your choice, and it starts off.</p></div>--</div></section>--<section class="ovp-section" aria-labelledby="h-more"><h2 id="h-more">More on OpenVibe</h2><ul class="ovp-more" role="list">--<li><a href="https://openvibe.chat/"><strong>OpenVibe.Chat</strong><span>Send pictures in rooms and servers: they go up to OpenVibe.Pics.</span></a></li>--<li><a href="${config.video.url}/"><strong>OpenVibe.Video</strong><span>Upload videos with AI captions and chapters.</span></a></li>--<li><a href="${config.baseUrl}/"><strong>OpenVibe.Download</strong><span>A private drive for every other kind of file.</span></a></li>--<li><a href="https://openvibe.community/"><strong>OpenVibe.Community</strong><span>Forums and pastes to post your pictures in.</span></a></li>--</ul></section>`,++${raw(showcase.steps({++ title: 'How it works',++ 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: '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.' },++ ],++}))}++${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: '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.` },++ { icon: 'text', title: 'Alt text', text: 'Say what a picture shows for people who cannot see it, or let AI draft it when you ask. It goes into every embed snippet.' },++ ],++}))}++${raw(showcase.features({++ title: 'The links a picture gets',++ lede: 'Each picture has a short name of eight letters and digits. These are the links it answers at:',++ items: [++ { icon: 'file', title: 'Its page', text: `${base}/i/<name>` },++ { icon: 'image', title: 'The picture itself, in the format you uploaded', text: `${base}/i/<name>.jpg` },++ { icon: 'sliders', title: 'Another width or format', text: `${base}/i/<name>/w/640.avif` },++ { icon: 'code', title: 'Markdown, with your alt text', text: `` },++ ],++}))}++${raw(showcase.code({++ title: 'Upload from your own app',++ lede: 'The API takes the picture as the request body, with an OpenVibe sign-in token, and answers with its links.',++ samples: [{ label: 'curl', lang: 'bash', code: curl }],++}))}++${raw(showcase.limits({ title: 'What a free account gets', columns: limitColumns, rows: limitRows }))}++${raw(showcase.faq({ title: 'Questions', items: faq.map((f) => [f.q, f.a]) }))}++${raw(showcase.features({++ title: 'What we promise, in writing',++ items: [++ { icon: 'shield', title: 'What happens to your picture', href: '/exif', text: 'The metadata — the GPS position, the camera, the time it was taken — is taken out by us before anyone sees the picture, and the file you uploaded is not kept.' },++ { icon: 'book', title: 'What you may put here', href: '/content-policy', text: 'What is not allowed, how a rule is enforced, and what this site does not do: opt-in discovery, public counts, and no adverts.' },++ { icon: 'brain', title: 'Pictures and AI', href: '/ai-training', text: "We don't train AI on your content. If that ever changes, it'll be your choice, and it starts off." },++ ],++}))}++${raw(showcase.network({++ service: 'pics',++ items: [++ { to: 'cam', href: 'https://openvibe.cam', what: 'Take a photo and save it here' },++ { to: 'studio', href: 'https://openvibe.studio', what: 'Edit your pictures with AI' },++ { to: 'community', href: 'https://openvibe.community', what: 'Share pictures in pastes and discussions' },++ { to: 'meme', href: 'https://openvibe.meme', what: 'Make a meme from a picture' },++ ],++}))}++<script>${raw(showcase.NETWORK_JS)}</script>`,});});@@@ -353,6 +378,7 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><const who = whoOf(req);const owner = pics.isOwner(row, who);const L = pics.links(row);++ if (req.method === 'GET' && gallery.isPublic(row) && !owner) await s.db.query(`INSERT INTO pics_views (image_id, visitor, day) SELECT i.id, $2, $3 FROM pics_images i WHERE i.id = $1 AND ${gallery.PUBLIC} ON CONFLICT DO NOTHING`, [row.id, pics.addressId(req.ip), s.iso().slice(0, 10)]);const albums = owner ? await store.albumsOf(s, who.requester) : [];const holding = owner ? await store.albumsHolding(s, row.id) : [];const title = row.title || 'Untitled picture';@@@ -361,10 +387,11 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><const likeRow = owner && /^[A-Za-z0-9]{4,16}$/.test(String(req.query.like || '')) ? await store.imageBySlug(s, String(req.query.like)) : null;const likeSlug = likeRow && likeRow.id !== row.id && likeRow.owner === row.owner ? likeRow.slug : null;return page(req, res, {-- title, noindex: true,++ title, noindex: !gallery.isPublic(row), index: gallery.isPublic(row),++ jsonLd: gallery.isPublic(row) ? { '@context': 'https://schema.org', '@type': 'ImageObject', name: title, contentUrl: L.direct, url: L.page, caption: row.description || title, author: { '@type': 'Person', name: row.author_name || 'OpenVibe member' } } : undefined,description: row.description || `${title}: a picture on ${SITE_NAME}.`,image: hidden ? undefined : L.direct, ogType: 'article',-- scripts: ['js/pics.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],++ scripts: ['js/pics.js', 'js/pics-gallery.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],...(owner ? { localStyles: ['css/labels.css'] } : {}),private: deleteKey ? true : undefined,body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}${owner && req.query.again ? notice('You had already uploaded this picture: here it is. Nothing was stored twice.', 'info') : ''}${owner && req.query.new && likeSlug ? notice(html`It looks like a picture you uploaded before: <a href="/i/${likeSlug}">that one</a>. If you meant to keep only one, delete the other.`, 'info') : ''}@@@ -376,11 +403,14 @@ ${labelsUi.chips(labelsUi.inForce(row))}<figure class="ovp-figure"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner, opened: true })}><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure><p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>${raw(shareButton({ url: L.page, title, text: row.description || `${title}: a picture on ${SITE_NAME}.`, image: L.direct, kind: 'picture', id: row.id }))}++${gallery.isPublic(row) ? html`<p>${row.author_name || 'OpenVibe member'} · ${(await gallery.counts(s, row.id)).views} views · <span data-like-count>${(await gallery.counts(s, row.id)).likes}</span> likes</p><button class="ovp-btn" data-like="${row.slug}">Like</button><p role="status" data-like-status></p>` : ''}++${!hidden ? html`<p class="ovp-gallery-tabs"><a class="ovp-btn" href="https://openvibe.studio/image-tools?picture=${row.slug}">Edit with AI</a><a class="ovp-btn" href="https://openvibe.meme/make?picture=${row.slug}">Make a meme</a><a class="ovp-btn" href="https://openvibe.cam/">Take a photo</a></p>` : ''}${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}</article>++${gallery.isPublic(row) ? html`<section class="ovp-section"><h2>Comments</h2><button class="ovp-btn" data-comments="${row.id}">Show Community discussion</button><div data-comments-list aria-live="polite"></div></section>` : ''}<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>--<p class="ovp-share-lede">No account needed: anyone with the link can open this picture and copy these. The direct link is the picture itself, at full size — no page around it.</p>++<p class="ovp-share-lede">These links follow the audience you choose in Share. Private pictures are visible only to you. The direct link is the picture itself, at full size — no page around it.</p><div class="ovp-link-grid">${linkCard('Direct link', 'The picture itself, full size.', L.direct, `snip-direct-${row.slug}`)}${linkCard('Markdown', 'For a chat or a post.', L.markdown, `snip-md-${row.slug}`)}@@@ -395,6 +425,7 @@ ${!hidden && variants.widthsFor(row).length ? html`<section class="ovp-section o${[['webp', 'WebP'], ['avif', 'AVIF'], ['jpg', 'JPEG'], ['png', 'PNG']].map(([fmt, label]) => html`<tr><th scope="row">${label}</th>${variants.widthsFor(row).map((w) => html`<td><a href="/i/${row.slug}/w/${w}.${fmt}" rel="nofollow">${w}</a></td>`)}</tr>`)}</tbody></table></section>` : ''}${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-yours"><h2 id="h-yours">Yours</h2>++<form class="ovp-form" method="post" action="/i/${row.slug}/publication"><label for="p-tags">Tags (comma separated)</label><input id="p-tags" name="tags" maxlength="330" value="${gallery.tags(row).join(', ')}"><p>Publishing makes this picture visible to everyone, lists it in the gallery, enables public counts, and lets search engines index it.</p><button class="ovp-btn" name="listed" value="${!row.listed}">${row.listed ? 'Remove from gallery' : 'Publish to gallery'}</button><p>Removing from the gallery keeps your sharing setting. Use Share to make it private again.</p></form><form class="ovp-form" method="post" action="/i/${row.slug}/edit"><label for="e-title">Title</label><input id="e-title" name="title" type="text" maxlength="120" value="${row.title}"><label for="e-desc">Description</label><textarea id="e-desc" name="description" rows="3" maxlength="2000">${row.description}</textarea>@@@ -458,6 +489,15 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" namereturn res.redirect(303, `/i/${row.slug}`);});++ r.post('/i/:slug/publication', mustSignIn('Sign in to publish your picture.'), budgeted('pics.image.write'), async (req, res) => {++ if (!sameSite(req, res, `/i/${req.params.slug}`)) return;++ const row = await ownPicture(req, res);++ if (!row) return;++ const out = await gallery.publish(ctx, row, req.viewer, { listed: req.body.listed === 'true', tags: String(req.body.tags || '').split(',').map((v) => v.trim()).filter(Boolean) });++ if (out.error) return picturePage(req, res, row, { message: out.error, tone: 'warn' }, 422);++ return res.redirect(303, `/i/${row.slug}`);++ });++r.post('/i/:slug/describe', mustSignIn('Sign in to change your picture.'), budgeted('pics.image.describe'), async (req, res) => {if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;const row = await ownPicture(req, res);@@@ -659,10 +699,10 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res// ── Discovery, legal, update log ────────────────────────r.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))-- .send(`# openvibe.pics: pictures are unlisted; only the home page is for search engines.\n${seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`], disallow: ['/i/', '/a/', '/mine', '/albums', '/staff', '/api/', '/auth/', '/upload'] })}`));++ .send(`# openvibe.pics: only explicitly published pictures are indexed.\n${seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`], disallow: ['/a/', '/mine', '/albums', '/staff', '/api/', '/auth/', '/upload'] })}`));// The indexable pages: the home, the update log, the rules and the policy pages. No picture or album is ever listed.-- r.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))-- .send(seo.sitemapXml(['/', '/updates', ...legal.PATHS, ...POLICY_PATHS].map((p) => ({ loc: `${base}${p}` })))));++ r.get('/sitemap.xml', async (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))++ .send(seo.sitemapXml(['/', '/updates', ...legal.PATHS, ...POLICY_PATHS].map((p) => ({ loc: `${base}${p}` })).concat((await s.db.many(`SELECT i.slug FROM pics_images i WHERE ${gallery.PUBLIC} ORDER BY i.published_at DESC LIMIT 50000`)).map((r) => ({ loc: `${base}/i/${r.slug}` }))))));// RFC 9116, canonical to this host (openvibe.pics).r.get('/.well-known/security.txt', securityTxt.handler(base));// The site as llms.txt describes it, shared by /llms.txt and /llms-full.txt (SEO-33). AGT-5: its own pages are@@@ -672,7 +712,7 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="resmarkdown: true,base,summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, any size or format.',-- details: 'Pictures are unlisted (reachable only by their link). 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 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).',sections: [{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }] },],@@@ -683,7 +723,7 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res{ title: 'Pictures and AI', url: `${base}/ai-training` },{ title: 'Terms', url: `${base}/terms` },{ title: 'DMCA', url: `${base}/dmca` },-- { title: 'Sitemap', url: `${base}/sitemap.xml`, note: 'only the home, the update log and the policy pages: no picture or album is ever listed' },++ { title: 'Sitemap', url: `${base}/sitemap.xml`, note: 'home, policies and explicitly published pictures' },{ title: 'Full text for language models', url: `${base}/llms-full.txt` },],});@@@ -122,8 +122,7 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {sub: 'The content policy, and what happens when a rule is broken',back: { href: '/', label: 'Home' },}),-- sec('The short answer', pm('OpenVibe.Pics is a link, not a gallery. A picture is reachable by anyone who has its link and by nobody else, so what you put here is what you are willing to share with the people you send it to. '-- + 'Most of the site works without an account, and nothing you upload is listed, ranked or shown to a search engine.')),++ sec('The short answer', pm('A picture is unlisted to begin with: anyone with the link can open it and nobody else. You choose whether to keep it that way, narrow it to specific people, or publish it to the public gallery. Only explicitly published pictures appear in discovery, search engines and public engagement counts.')),sec('Not allowed here', rows(contentRules(reasons, reasonText).map(([title, sub]) => kit.row({ title, sub }))), 'These are the reasons a picture can be reported for. Each one is a rule, not a suggestion.'),sec('How a rule is enforced', rows([kit.row({ title: 'Every picture has a report form', sub: 'Whoever has the link can report it with one of the reasons above. One report per person, so a single reader cannot hide a picture on their own.' }),@@@ -133,8 +132,8 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {kit.row({ title: 'You can delete your own, at any time', sub: 'Every picture you own has a delete button, and an album can be deleted in one action. The picture is gone from this site; copies other sites already loaded can show for up to ten minutes.' }),])),sec('What this site does not do', rows([-- kit.row({ title: 'No feed to scroll', sub: 'There is no list of other people\'s pictures, no ranking, and no gallery of what was uploaded today.' }),-- kit.row({ title: 'No counts to chase', sub: 'No likes, no followers, no numbers of how a picture is doing. There is nothing to count, because nothing is ranked.' }),++ kit.row({ title: 'Public sharing is opt-in', sub: 'Publish a picture to include it in Popular, New and Rising. Remove it from the gallery whenever you choose.' }),++ kit.row({ title: 'Counts on public pictures only', sub: 'Published pictures show likes and daily deduplicated views. Private and link-only pictures have no public counts.' }),kit.row({ title: 'No adverts, nothing sold', sub: 'No advertising trackers, and your pictures are not sold or used for anyone else\'s advertising.' }),kit.row({ title: 'No app to install', sub: 'Every picture and album opens in a browser, signed in or not. There is no app and no account gate in front of the link.' }),])),nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++const express = require('express');++const crypto = require('crypto');++const { asyncRouter } = require('../http/router');++const images = require('./store');++function resumable(ctx, { person, problem, whoOf }) {++ const r = asyncRouter(); const { s, pics, config, limits } = ctx;++ const wire = (u) => ({ id: u.id, size: u.size, offset: u.offset_bytes, expires_at: u.expires_at, image_id: u.image_id });++ r.use(person);++ 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.');++ 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()]);++ });++ 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));++ });++ async function own(req, lock = false) {++ return s.db.maybe(`SELECT * FROM pics_uploads WHERE id = $1 AND owner = $2 AND expires_at > $3 ${lock ? 'FOR UPDATE' : ''}`, [req.params.id, whoOf(req).requester, s.iso()]);++ }++ r.get('/:id', async (req, res) => { const u = await own(req); return u ? res.json(wire(u)) : problem(req, res, 404, 'pics.upload_not_found', 'No such upload.'); });++ r.patch('/:id', limits.budget('pics.image.write'), express.raw({ type: () => true, limit: '1mb' }), async (req, res) => s.tx(async () => {++ const u = await own(req, true);++ if (!u) return problem(req, res, 404, 'pics.upload_not_found', 'No such upload.');++ const offset = Number(req.get('upload-offset'));++ if (!req.get('upload-offset') || !Number.isInteger(offset) || offset !== u.offset_bytes || u.image_id) return problem(req, res, 409, 'pics.upload_offset', 'Read the upload status and resume at its offset.');++ if (!Buffer.isBuffer(req.body) || !req.body.length || offset + req.body.length > u.size) return problem(req, res, 422, 'pics.upload_size', 'The chunk must fit the declared upload size.');++ await s.db.query('INSERT INTO pics_upload_chunks (upload_id, offset_bytes, bytes) VALUES ($1,$2,$3)', [u.id, offset, req.body]);++ await s.db.query('UPDATE pics_uploads SET offset_bytes = $2 WHERE id = $1', [u.id, offset + req.body.length]);++ return res.json(wire({ ...u, offset_bytes: offset + req.body.length }));++ }));++ r.post('/:id/complete', limits.budget('pics.image.upload'), async (req, res) => s.tx(async () => {++ const u = await own(req, true);++ if (!u) return problem(req, res, 404, 'pics.upload_not_found', 'No such upload.');++ 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 });++ 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]);++ return res.status(201).json(pics.toWire(out.image, { owner: true }));++ }));++ r.delete('/:id', async (req, res) => { await s.db.query('DELETE FROM pics_uploads WHERE id = $1 AND owner = $2', [req.params.id, whoOf(req).requester]); return res.status(204).end(); });++ return r;++}++module.exports = { resumable };@@@ -136,8 +136,9 @@ function createPics({ s, media, config, log = console, events = null, sharing =});if (events && !anonymous) await events.pictureUploaded(r);return r; });-- // Everyone by default: a picture has always been reachable by its link. The person can narrow it in the sheet.-- if (sharing) await sharing.register(row.owner, 'picture', row.id, 'public');++ // 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');return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}), ...(similar ? { similar: { id: similar.id, slug: similar.slug } } : {}) };}@@@ -261,6 +262,7 @@ function createPics({ s, media, config, log = console, events = null, sharing =ai: row.ai_described_at ? { caption: row.ai_caption || null, tags: tagsOf(row), described_at: row.ai_described_at } : null,// 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),...(owner ? { report_count: Number(row.report_count) } : {}),});@@@ -95,7 +95,9 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalTif (ref.kind === 'picture' || ref.kind === 'album') {const table = ref.kind === 'picture' ? 'pics_images' : 'pics_albums';await tx.exec(`UPDATE ${table} SET audience = $1 WHERE id = $2`, [audience, ref.id]);++ if (ref.kind === 'picture' && audience !== 'public') await tx.exec('UPDATE pics_images SET listed = false WHERE id = $1', [ref.id]);if (ref.kind === 'album') {++ if (audience !== 'public') await tx.exec('UPDATE pics_images SET listed = false WHERE id IN (SELECT image_id FROM pics_album_images WHERE album_id = $1)', [ref.id]);await tx.exec('UPDATE pics_images SET audience = $1 WHERE id IN (SELECT image_id FROM pics_album_images WHERE album_id = $2)', [audience, ref.id]);await tx.exec("UPDATE shares SET audience = $1, revision = revision + 1 WHERE service = $2 AND kind = 'picture' AND resource_id IN (SELECT image_id FROM pics_album_images WHERE album_id = $3)", [audience, SERVICE, ref.id]);}nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++const assert = require('assert');++const sharp = require('sharp');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++function hostClient(t) {++ return (p, o = {}) => new Promise((resolve, reject) => {++ const headers = { Host: 'openvibe.pics', ...(o.headers || {}) };++ if (o.as) headers.cookie = t.signIn(o.as);++ let body = o.body;++ if (o.json !== undefined) { body = Buffer.from(JSON.stringify(o.json)); headers['content-type'] = 'application/json'; }++ if (o.form !== undefined) { body = Buffer.from(new URLSearchParams(o.form).toString()); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ if (body != null) headers['content-length'] = String(Buffer.byteLength(body));++ const u = new URL(t.base + p);++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: o.method || (body != null ? 'POST' : 'GET'), headers }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => {++ const buf = Buffer.concat(chunks);++ const text = buf.toString('utf8');++ resolve({ status: res.statusCode, headers: { get: (k) => res.headers[String(k).toLowerCase()] || null }, text, buffer: buf, json() { return JSON.parse(text); } });++ });++ });++ req.on('error', reject);++ if (body != null) req.write(body);++ req.end();++ });++}++++++(async () => {++ const t = await boot(); const get = hostClient(t); const a = t.network.addUser('galleryauthor'); const b = t.network.addUser('reader');++ const same = { 'sec-fetch-site': 'same-origin' };++ try {++ const image = (await t.ctx.pics.upload({requester: `user:${a.subject}`}, {buffer: await sharp({create:{width:120,height:90,channels:3,background:'#246'}}).png().toBuffer(), name:'forest.png'}, {title:'Forest'})).image;++ const pub = (as, json) => get(`/api/v1/images/${image.slug}/publication`, { as, json, headers: same });++ await check('resumable chunks enforce ownership, offsets and idempotent completion', async () => {++ const bytes = await sharp({create:{width:31,height:41,channels:3,background:'#a42'}}).png().toBuffer();++ const created = await get('/api/v1/uploads',{as:a,json:{size:bytes.length,content_type:'image/png'},headers:same});++ assert.equal(created.status,201,created.text); const u=created.json();++ assert.equal((await get(`/api/v1/uploads/${u.id}`,{as:b})).status,404);++ const send = (offset, body) => get(`/api/v1/uploads/${u.id}`,{as:a,method:'PATCH',body,headers:{...same,'content-type':'application/octet-stream','upload-offset':String(offset)}});++ assert.equal((await send(1,bytes)).status,409);++ assert.equal((await send(0,bytes)).status,200);++ const complete = () => get(`/api/v1/uploads/${u.id}/complete`,{as:a,method:'POST',headers:same});++ const first = await complete(); assert.equal(first.status,201,first.text);++ const again = await complete(); assert.equal(again.status,200,again.text); assert.equal(first.json().id,again.json().id);++ });++ await check('new uploads are unlisted: reachable by their link, never in the gallery', async () => {++ assert.equal((await get(`/i/${image.slug}`)).status,200);++ assert.equal((await get(`/api/v1/pictures/${image.id}`)).status,200);++ assert.equal((await get('/api/v1/gallery')).json().pictures.length,0);++ assert.equal((await get(`/i/${image.slug}`,{as:a})).status,200);++ });++ await check('publishing requires owner, same origin and valid tags', async () => {++ // The picture is visible by its link, so a stranger is refused, not told it is missing.++ assert.equal((await pub(b,{listed:true})).status,403);++ assert.equal((await get(`/api/v1/images/${image.slug}/publication`,{as:a,json:{listed:true}})).status,403);++ assert.equal((await pub(a,{listed:true,tags:['<script>']})).status,422);++ assert.equal((await pub(a,{listed:true,tags:['forest']})).status,200);++ });++ await check('public gallery, search, HTML and indexing expose only published pictures', async () => {++ const list = (await get('/api/v1/gallery?tag=forest')).json(); assert.equal(list.pictures.length,1); assert.equal(list.pictures[0].title,'Forest');++ assert.equal((await get('/api/v1/gallery?q=unmatched')).json().pictures.length,0);++ const home = await get('/'); assert.equal(home.status,200,home.text); assert.ok(home.text.includes('Forest'));++ const page = await get(`/i/${image.slug}`); assert.ok(page.text.includes('ImageObject')); assert.ok(page.text.includes('index, follow'));++ assert.ok((await get('/sitemap.xml')).text.includes(`/i/${image.slug}`));++ });++ await check('likes and daily views are idempotent and public-only', async () => {++ for(let i=0;i<2;i++) assert.equal((await get(`/api/v1/images/${image.slug}/like`,{as:b,method:'PUT',headers:same})).status,200);++ await get(`/i/${image.slug}`); await get(`/i/${image.slug}`);++ const row = (await get('/api/v1/gallery')).json().pictures[0]; assert.equal(row.likes,1); assert.equal(row.views,1);++ });++ await check('narrowing sharing removes picture from discovery immediately', async () => {++ await t.ctx.sharing.setAudience({kind:'user',subject:a.subject},'picture',image.id,'private');++ assert.equal((await get('/api/v1/gallery')).json().pictures.length,0);++ assert.equal((await get(`/api/v1/pictures/${image.id}`)).status,404);++ assert.equal((await get(`/i/${image.slug}/thumb.webp`)).status,404);++ });++ await check('moderation removes public pictures and owner can unpublish', async () => {++ assert.equal((await pub(a,{listed:true})).status,200);++ await t.ctx.s.db.query("UPDATE pics_images SET state = 'hidden' WHERE id = $1",[image.id]);++ assert.equal((await get('/api/v1/gallery')).json().pictures.length,0);++ assert.equal((await pub(a,{listed:true})).status,422);++ assert.equal((await pub(a,{listed:false})).status,200);++ });++ } finally { await t.close(); } done();++})().catch(e=>{process.stderr.write(e.stack+'\n');process.exit(1);});@@@ -72,8 +72,8 @@ const PAGES = ['/exif', '/content-policy', '/ai-training'];assert.ok(page.includes(reason), `the page says: ${reason}`);}assert.ok(page.includes(`${t.config.pics.reportsToHide} reports hide a picture`), 'the number of reports is the configured one');-- assert.ok(page.includes('No feed to scroll'), 'it says there is no feed');-- assert.ok(page.includes('No counts to chase'), 'it says there are no counts');++ assert.ok(page.includes('Public sharing is opt-in'), 'publication requires consent');++ assert.ok(page.includes('Counts on public pictures only'), 'counts are limited to published pictures');assert.ok(page.includes('/dmca'), 'it points at the DMCA page');});@@@ -92,10 +92,10 @@ const PAGES = ['/exif', '/content-policy', '/ai-training'];assert.ok(home.includes('The hidden data is removed by us'), 'the home says who removes the metadata');assert.ok(home.includes('href="/exif"'), 'and links the page that explains it');for (const path of PAGES) assert.ok(home.includes(`href="${path}"`), `the home links ${path}`);-- assert.ok(home.includes('no counts to chase'), 'the home says why there is no feed');++ assert.ok(home.includes('keep yours unlisted'), 'the home explains that an upload stays unlisted');// The line sits under the upload, not above it: the page's main job comes first.assert.ok(home.indexOf('data-pics-upload') < home.indexOf('The hidden data is removed by us'), 'the upload comes first');-- assert.ok(home.indexOf('The hidden data is removed by us') < home.indexOf('>How it works</h2>'), 'and the line before the rest');++ assert.ok(home.indexOf('The hidden data is removed by us') < home.indexOf('How it works</span></h2>'), 'and the line before the rest');});await check('the policies are in the sitemap and in llms.txt, and not blocked for crawlers', async () => {@@@ -1,7 +1,7 @@'use strict';/*** Sharing on MediaHub pictures and albums (design 2026-11-06, VISIBILITY-EVERYWHERE step 4;-- * openvibe-sdk/share). Everyone by default (a picture has always been reachable by its link),++ * openvibe-sdk/share). Unlisted by default (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 Everyone, owned by its uploader', async () => {++ await check('a new picture is registered for anyone with the link, owned by its uploader', async () => {const img = await upload();const s = await row(img.id);-- assert.strictEqual(s.audience, 'public');++ assert.strictEqual(s.audience, 'link');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);@@@ -73,7 +73,7 @@ function multipart(fields, file) {assert.ok(/<script type="application\/ld\+json">[^<]*"@type":"WebSite"/.test(home.text), 'the home says what the site is (JSON-LD)');assert.strictEqual((await t.get('/files', { headers: PICS, as: kim })).status, 404, 'no drive pages on the pics host');assert.ok((await t.get('/', { headers: { Host: 'openvibe.download' } })).text.indexOf('Share pictures.') === -1, 'the drive host is not Pics');-- assert.strictEqual((await t.get('/robots.txt', { headers: PICS })).text.includes('Disallow: /i/'), true, 'pictures are not crawled');++ assert.strictEqual((await t.get('/robots.txt', { headers: PICS })).text.includes('Disallow: /i/'), false, 'public pictures can be crawled; private pages stay noindex');});await check('the home: how it works, the links, the API, the limits from config, questions that match its FAQPage', async () => {@@@ -81,13 +81,14 @@ function multipart(fields, file) {assert.strictEqual((home.match(/<h1[\s>]/g) || []).length, 1, 'one h1');assert.match(home, /<title>OpenVibe\.Pics: share and embed pictures with a link<\/title>/);const sections = ['How it works', 'The links a picture gets', 'Upload from your own app', 'What a free account gets', 'Questions', 'More on OpenVibe'];-- assert.deepStrictEqual(sections.filter((h) => !home.includes(`>${h}</h2>`)), [], 'every section is there');++ // The showcase kit puts every section title in a span inside its heading.++ assert.deepStrictEqual(sections.filter((h) => !home.includes(`${h}</span></h2>`)), [], 'every section is there');assert.ok(/<td[^>]*>20 MB<\/td>/.test(home) && /<td[^>]*>200<\/td>/.test(home) && /<td[^>]*>5 GB<\/td>/.test(home), 'the limits are the configured ones');-- assert.ok(home.includes('/api/v1/images \\\n -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n'), 'the curl sample keeps its line breaks');++ assert.ok(home.includes('/api/v1/images \\\n -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n'), 'the curl sample keeps its line breaks');const ld = [...home.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1])));const faq = ld.find((x) => x['@type'] === 'FAQPage');// The questions section's own disclosures: the shared footer's "More from OpenVibe" is one too (openvibe-shared 4.15).-- const questions = home.slice(home.indexOf('>Questions</h2>'), home.indexOf('>More on OpenVibe</h2>'));++ const questions = home.slice(home.indexOf('Questions</span></h2>'), home.indexOf('More on OpenVibe</span></h2>'));const shown = [...questions.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the FAQPage is on the page');const map = (await t.get('/sitemap.xml', { headers: PICS })).text;@@@ -143,7 +144,7 @@ function multipart(fields, file) {assert.strictEqual(page.status, 200);assert.ok(page.text.includes('The coast'));assert.ok(page.text.includes(`https://openvibe.pics${direct}`), 'the direct link is on the page');-- assert.ok(page.text.includes('No account needed'), 'the copy panel says no account is needed');++ assert.ok(page.text.includes('follow the audience you choose'), 'the copy panel explains access control');assert.ok(page.text.includes('[img]https://openvibe.pics'), 'the BBCode snippet is on the page');assert.ok(page.text.includes('data-ovk-copy'), 'the links have a copy button (the kit)');assert.ok(page.text.includes('noindex'));
@@ -532,3 +532,27 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -532,3 +532,27 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
- openvibe-sdk: v0.123.0
- openvibe-sdk: v0.123.0
- openvibe-shared: v4.173.0
- openvibe-shared: v4.173.0
<!-- versions:end -->
<!-- versions:end -->
+
+### Public Pics gallery
+
+`GET /api/v1/gallery?sort=popular|new|rising&q=&tag=&cursor=&limit=30` lists only active,
+explicitly published pictures whose sharing audience is Everyone. A new upload is unlisted: anyone with
+the link can open it and nobody else, and no picture is opted in by migration 0028. The owner publishes using
+`POST /api/v1/images/:slug/publication` with `{ "listed": true, "tags": ["landscape"] }`.
+Setting `listed` false removes discovery without changing access; narrowing Share also clears publication.
+`GET /api/v1/pictures/:id` respects the current audience, and authenticated readers can retrieve raster
+bytes at `GET /api/v1/images/:slug/bytes`. Public likes are idempotent PUT/DELETE `/images/:slug/like`.
+Views count one keyed address per day on public picture pages; private pictures expose no counts.
+
+Resumable uploads: POST `/api/v1/uploads` with `{size, content_type, name, title}`, PATCH `/uploads/:id`
+with raw chunks up to 1 MiB and `Upload-Offset`, GET the same resource to resume, POST
+`/uploads/:id/complete` to re-encode and save privately, or DELETE to cancel. Sessions expire after a day;
+four unfinished uploads per person, bounded by the ordinary image size, quota and daily limits. Complete
+is idempotent. Expired chunks are reclaimed when new sessions are created. All writes require a person;
+session-cookie writes must be same-origin. `pics.picture@1` and `pics.gallery@1` describe the responses.
+
+Cross-site rollout: deploy the Community Pics discussion policy before enabling discussions; it checks
+current public visibility on every read. Studio's image-tool adapter requires `STUDIO_IMAGE_WORKER_URL`
+and returns an explicit unavailable state until a worker is configured. No image worker was invoked in
+this change. Cam already posts captured pictures to the existing upload endpoint. Shared `ov-upload`
+is not in the pinned Shared release yet; Pics retains its existing drop/paste/choose uploader.
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+-- phase: expand
+-- Discovery is separate from access: no existing picture is opted in by this migration.
+ALTER TABLE pics_images ADD COLUMN IF NOT EXISTS listed boolean NOT NULL DEFAULT false;
+ALTER TABLE pics_images ADD COLUMN IF NOT EXISTS tags text NOT NULL DEFAULT '[]';
+ALTER TABLE pics_images ADD COLUMN IF NOT EXISTS author_name text NOT NULL DEFAULT '';
+ALTER TABLE pics_images ADD COLUMN IF NOT EXISTS published_at timestamptz;
+CREATE INDEX IF NOT EXISTS pics_gallery_new ON pics_images (published_at DESC, id DESC) WHERE listed = true AND state = 'active' AND audience = 'public';
+CREATE TABLE IF NOT EXISTS pics_likes (
+ image_id text NOT NULL REFERENCES pics_images(id) ON DELETE CASCADE,
+ actor text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (image_id, actor)
+);
+CREATE TABLE IF NOT EXISTS pics_views (
+ image_id text NOT NULL REFERENCES pics_images(id) ON DELETE CASCADE,
+ visitor text NOT NULL, day text NOT NULL, PRIMARY KEY (image_id, visitor, day)
+);
+CREATE TABLE IF NOT EXISTS pics_uploads (
+ id text PRIMARY KEY, owner text NOT NULL, size integer NOT NULL CHECK (size > 0),
+ content_type text NOT NULL, name text NOT NULL, title text NOT NULL,
+ offset_bytes integer NOT NULL DEFAULT 0, expires_at timestamptz NOT NULL,
+ image_id text REFERENCES pics_images(id) ON DELETE SET NULL
+);
+CREATE TABLE IF NOT EXISTS pics_upload_chunks (
+ upload_id text NOT NULL REFERENCES pics_uploads(id) ON DELETE CASCADE,
+ offset_bytes integer NOT NULL, bytes bytea NOT NULL,
+ PRIMARY KEY (upload_id, offset_bytes)
+);
@@ -211,3 +211,64 @@
@@ -211,3 +211,64 @@
.ovp-tile-cover > .ovp-tile { height: 100%; border-radius: inherit; }
.ovp-tile-cover > .ovp-tile { height: 100%; border-radius: inherit; }
.ovp-title + .ovk-labels { margin: -4px 0 12px; }
.ovp-title + .ovk-labels { margin: -4px 0 12px; }
@media (max-width: 640px) { .ovp-tile-cover { border-radius: 8px; } }
@media (max-width: 640px) { .ovp-tile-cover { border-radius: 8px; } }
+
+/* The public gallery (owner's design pass, 11-09): pictures first. A masonry of whole pictures at their own shape, each
+ with one quiet caption under it: the title, who and the counts on one line, tags as small chips. Above it, one search
+ field (its glyph and button inside) beside a segmented Popular / New / Rising. */
+.ovp-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+.ovp-gallery-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 18px 0 16px; }
+.ovp-gallery-search { flex: 1 1 320px; display: flex; align-items: center; gap: 8px; min-width: 0; min-height: 46px; padding: 0 5px 0 14px; border: 1px solid var(--ovp-line); border-radius: 999px; background: var(--ovp-card); }
+.ovp-gallery-search:focus-within { border-color: var(--ovp-pink); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovp-pink) 22%, transparent); }
+.ovp-gallery-search .ovp-gc-ic { color: var(--ovp-faint); }
+.ovp-gallery-search input { flex: 1; min-width: 0; min-height: 44px; padding: 0; border: 0; outline: 0; background: transparent; color: var(--text-primary, #e6edf7); font: inherit; }
+.ovp-gallery-search button { flex: none; min-height: 36px; padding: 0 16px; border: 0; border-radius: 999px; background: var(--ovp-pink); color: var(--ovp-on-pink); font: inherit; font-weight: 700; cursor: pointer; }
+.ovp-gallery-tabs { display: inline-flex; flex: none; gap: 2px; padding: 3px; border: 1px solid var(--ovp-line); border-radius: 999px; background: var(--ovp-card); }
+.ovp-gallery-tabs a { display: inline-flex; align-items: center; min-height: 38px; padding: 0 16px; border-radius: 999px; color: var(--ovp-dim); font-weight: 600; text-decoration: none; }
+.ovp-gallery-tabs a:hover { color: var(--text-primary, #e6edf7); }
+.ovp-gallery-tabs [aria-current] { background: color-mix(in srgb, var(--ovp-pink) 18%, transparent); color: var(--text-primary, #e6edf7); }
+.ovp-gallery-tagged { margin: -4px 0 14px; color: var(--ovp-dim); }
+.ovp-gallery-tagged a { margin-left: 8px; }
+.ovp-gallery { columns: 4 220px; column-gap: 16px; }
+.ovp-gallery-card { break-inside: avoid; margin: 0 0 18px; }
+.ovp-gc-pic { display: block; overflow: hidden; border-radius: 14px; background: var(--ovp-card); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ovp-line) 70%, transparent); }
+.ovp-gc-pic img { display: block; width: 100%; height: auto; transition: transform .35s ease, filter .35s ease; }
+.ovp-gallery-card:hover .ovp-gc-pic img { transform: scale(1.03); filter: brightness(1.06); }
+@media (prefers-reduced-motion: reduce) { .ovp-gc-pic img { transition: none; } .ovp-gallery-card:hover .ovp-gc-pic img { transform: none; } }
+.ovp-gc-body { padding: 9px 4px 0; }
+.ovp-gc-title { margin: 0; font-size: 15px; line-height: 1.3; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovp-gc-title a { color: var(--text-primary, #e6edf7); text-decoration: none; }
+.ovp-gc-title a:hover { text-decoration: underline; text-underline-offset: 3px; }
+.ovp-gc-title a::after { content: none; }
+.ovp-gc-meta { display: flex; align-items: center; gap: 12px; margin: 4px 0 0; font-size: 13px; color: var(--ovp-dim); min-width: 0; }
+.ovp-gc-by { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovp-gc-n { flex: none; display: inline-flex; align-items: center; gap: 4px; font-variant-numeric: tabular-nums; }
+.ovp-gc-ic { display: inline-grid; place-items: center; flex: none; }
+.ovp-gc-ic svg { display: block; }
+.ovp-gc-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; }
+.ovp-tag { display: inline-flex; align-items: center; min-height: 26px; padding: 0 10px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--ovp-dim); font-size: 12.5px; font-weight: 600; text-decoration: none; }
+.ovp-tag:hover { background: color-mix(in srgb, var(--ovp-pink) 18%, transparent); color: var(--text-primary, #e6edf7); }
+.ovp-gallery-empty { padding: 30px 0; }
+.ovp-drop--compact .ovp-drop-icon, .ovp-drop--compact .ovp-drop-fields { display: none; }
+.ovp-drop--compact .ovp-drop-inner { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 14px; }
+.ovp-drop--compact .ovp-drop-title { margin: 0; font-size: 16px; }
+.ovp-drop--compact .ovp-drop-hint { flex-basis: 100%; font-size: 13px; }
+@media (max-width: 600px) {
+ .ovp-gallery { columns: 2; column-gap: 10px; }
+ .ovp-gallery-card { margin-bottom: 14px; }
+ .ovp-gc-pic { border-radius: 12px; }
+ .ovp-gc-title { font-size: 14px; }
+ .ovp-gc-meta { gap: 8px; font-size: 12px; }
+ .ovp-gc-meta .ovp-gc-n:last-child { display: none; }
+ .ovp-gallery-bar { gap: 10px; }
+ .ovp-gallery-search { flex-basis: 100%; }
+ .ovp-gallery-tabs { width: 100%; }
+ .ovp-gallery-tabs a { flex: 1; justify-content: center; min-height: 40px; }
+ .ovp-tag { min-height: 32px; }
+}
+
+.ovp-drop--compact .ovp-drop-inner { flex-direction: row; text-align: left; }
+.ovp-drop--compact .ovp-drop-inner p { margin: 0; }
+.ovp-drop--signin.ovp-drop--compact .ovp-drop-title { display: none; }
+.ovp-drop--signin.ovp-drop--compact .ovp-drop-hint { display: block; flex: 1; }
+.ovp-gallery-search input { background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: 8px; min-height: 44px; padding: 8px 12px; }
+.ovp-btn { min-height: 44px; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+/* Public gallery: progressive loading and explicit, authenticated likes. */
+(function () {
+ 'use strict';
+ var next = document.querySelector('[data-gallery-next]'), grid = document.querySelector('[data-gallery]'), busy = false;
+ async function more(event) {
+ if (event) event.preventDefault();
+ if (busy || !next) return;
+ busy = true;
+ var status = document.querySelector('[data-gallery-status]');
+ try {
+ var response = await fetch(next.href, { credentials: 'same-origin' });
+ if (!response.ok) throw new Error('Could not load pictures. Use More pictures to try again.');
+ var doc = new DOMParser().parseFromString(await response.text(), 'text/html');
+ doc.querySelectorAll('[data-gallery] > article').forEach(function (card) { grid.appendChild(document.importNode(card, true)); });
+ var link = doc.querySelector('[data-gallery-next]');
+ if (link) next.href = link.href; else { next.remove(); next = null; }
+ status.textContent = 'More pictures loaded.';
+ } catch (error) { status.textContent = error.message; }
+ finally { busy = false; }
+ }
+ if (next) {
+ next.addEventListener('click', more);
+ if ('IntersectionObserver' in window) new IntersectionObserver(function (entries) { if (entries[0].isIntersecting) more(); }, { rootMargin: '300px' }).observe(next);
+ }
+ var discussion = document.querySelector('[data-comments]');
+ if (discussion) discussion.addEventListener('click', async function () {
+ var box = document.querySelector('[data-comments-list]'); discussion.disabled = true;
+ try {
+ var response = await fetch('https://openvibe.community/api/v1/comments/threads/resolve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref: { service: 'pics', type: 'picture', id: discussion.dataset.comments } }) });
+ if (!response.ok) throw new Error('Discussion is unavailable. Try again later.');
+ var result = await response.json(), thread = result.thread;
+ if (!thread || !/^cth_[A-Za-z0-9_-]+$/.test(thread.id)) throw new Error('Discussion is unavailable.');
+ var listing = await fetch('https://openvibe.community/api/v1/comments/threads/' + encodeURIComponent(thread.id));
+ if (!listing.ok) throw new Error('Comments could not be loaded.');
+ var data = await listing.json(); box.replaceChildren();
+ (data.comments || []).forEach(function (comment) { var p = document.createElement('p'); p.textContent = comment.message; box.appendChild(p); });
+ var link = document.createElement('a'); link.href = 'https://openvibe.community/c/' + encodeURIComponent(thread.id); link.textContent = 'Join the discussion on Community'; box.appendChild(link);
+ } catch (error) { box.textContent = error.message; }
+ finally { discussion.disabled = false; }
+ });
+ var like = document.querySelector('[data-like]');
+ if (like) like.addEventListener('click', async function () {
+ like.disabled = true;
+ var status = document.querySelector('[data-like-status]');
+ try {
+ var removing = like.getAttribute('aria-pressed') === 'true';
+ var response = await fetch('/api/v1/images/' + encodeURIComponent(like.dataset.like) + '/like', { method: removing ? 'DELETE' : 'PUT', credentials: 'same-origin' });
+ if (!response.ok) throw new Error(response.status === 401 ? 'Sign in to like this picture.' : 'Could not update your like. Try again.');
+ var data = await response.json();
+ document.querySelector('[data-like-count]').textContent = data.likes;
+ like.setAttribute('aria-pressed', String(!removing)); like.textContent = removing ? 'Like' : 'Liked'; status.textContent = '';
+ } catch (error) { status.textContent = error.message; }
+ finally { like.disabled = false; }
+ });
+})();
@@ -46,6 +46,9 @@ const TABLES = [
@@ -46,6 +46,9 @@ const TABLES = [
{ table: 'pics_albums', subject: 'owner', value: AS_REQUESTER, file: 'pics_albums.json' },
{ table: 'pics_albums', subject: 'owner', value: AS_REQUESTER, file: 'pics_albums.json' },
{ table: 'pics_images', subject: 'owner', value: AS_REQUESTER, file: 'pics_images.json' },
{ table: 'pics_images', subject: 'owner', value: AS_REQUESTER, file: 'pics_images.json' },
{ table: 'pics_usage', subject: 'uploader', value: AS_REQUESTER, file: 'pics_usage.json' },
{ table: 'pics_usage', subject: 'uploader', value: AS_REQUESTER, file: 'pics_usage.json' },
+ // A Pics resumable upload that never finished (migrations/0028_pics_gallery.sql) is this service's own state,
+ // like mh_uploads: erased with the account, not part of the export. The chunks cascade from it.
+ { table: 'pics_uploads', subject: 'owner', value: AS_REQUESTER, file: null },
// OpenVibe.Video uploads (migrations/0008_video_uploads.sql): the reports a person filed, then their videos (other
// OpenVibe.Video uploads (migrations/0008_video_uploads.sql): the reports a person filed, then their videos (other
// people's reports on them cascade). The bytes are Media VODs, deleted with extraErase.
// people's reports on them cascade). The bytes are Media VODs, deleted with extraErase.
{ table: 'video_upload_reports', subject: 'reporter', value: AS_REQUESTER, file: 'video_upload_reports.json' },
{ table: 'video_upload_reports', subject: 'reporter', value: AS_REQUESTER, file: 'video_upload_reports.json' },
@@ -138,7 +138,7 @@ async function createApp(opts = {}) {
@@ -138,7 +138,7 @@ async function createApp(opts = {}) {
mediaSrc: ["'self'", 'blob:', ...MEDIA_ORIGINS],
mediaSrc: ["'self'", 'blob:', ...MEDIA_ORIGINS],
// The watch page counts a view on OpenVibe.Live with a beacon (Live's /api/views).
// The watch page counts a view on OpenVibe.Live with a beacon (Live's /api/views).
// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).
// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).
- connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events', ...MEDIA_ORIGINS, liveOrigin],
+ connectSrc: ["'self'", 'https://openvibe.community', 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events', ...MEDIA_ORIGINS, liveOrigin],
frameSrc: ["'self'", 'https://openvibe.network'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'none'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
objectSrc: ["'none'"],
@@ -29,6 +29,7 @@ const { asyncRouter } = require('../http/router');
@@ -29,6 +29,7 @@ const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { sameOrigin } = require('../http/principal');
const store = require('./store');
const store = require('./store');
const contentLabels = require('../labels');
const contentLabels = require('../labels');
+const gallery = require('./gallery');
function createPicsApi(ctx) {
function createPicsApi(ctx) {
const { config, s, principal, limits } = ctx;
const { config, s, principal, limits } = ctx;
@@ -64,6 +65,8 @@ function createPicsApi(ctx) {
@@ -64,6 +65,8 @@ function createPicsApi(ctx) {
if (!row) { problem(req, res, 404, 'pics.not_found', 'No such picture.'); return null; }
if (!row) { problem(req, res, 404, 'pics.not_found', 'No such picture.'); return null; }
if (row.state === 'removed') { problem(req, res, 410, 'pics.taken_down', 'This picture was taken down.'); return null; }
if (row.state === 'removed') { problem(req, res, 410, 'pics.taken_down', 'This picture was taken down.'); return null; }
if (!pics.canSee(row, whoOf(req))) { problem(req, res, 404, 'pics.not_found', 'No such picture.'); return null; }
if (!pics.canSee(row, whoOf(req))) { problem(req, res, 404, 'pics.not_found', 'No such picture.'); return null; }
+ const viewer = req.principal.kind === 'user' ? { kind: 'user', subject: req.principal.requester.slice(5) } : req.viewer;
+ if (!await ctx.sharing.canView(viewer, 'picture', row.id)) { problem(req, res, 404, 'pics.not_found', 'No such picture.'); return null; }
return row;
return row;
}
}
async function loadOwn(req, res) {
async function loadOwn(req, res) {
@@ -73,6 +76,41 @@ function createPicsApi(ctx) {
@@ -73,6 +76,41 @@ function createPicsApi(ctx) {
return row;
return row;
}
}
+ r.use('/uploads', require('./resumable').resumable(ctx, { person, problem, whoOf }));
+
+ r.get('/gallery', async (req, res) => {
+ const result = await gallery.browse(s, req.query);
+ return res.json({ pictures: result.rows.map((row) => gallery.wire(pics, row)), next: result.next, sort: result.sort });
+ });
+ r.get('/pictures/:id', async (req, res) => {
+ const row = await store.imageById(s, String(req.params.id));
+ const viewer = req.principal.kind === 'user' ? { kind: 'user', subject: req.principal.requester.slice(5) } : req.viewer;
+ if (!row || !pics.canSee(row, whoOf(req)) || !await ctx.sharing.canView(viewer, 'picture', row.id)) return problem(req, res, 404, 'pics.not_found', 'No such picture.');
+ const extra = gallery.isPublic(row) ? await gallery.counts(s, row.id) : {};
+ return res.json({ ...pics.toWire(row, { owner: pics.isOwner(row, whoOf(req)) }), ...extra });
+ });
+ r.post('/images/:slug/publication', person, express.json({ limit: '8kb' }), limits.budget('pics.image.write'), async (req, res) => {
+ const row = await loadOwn(req, res);
+ if (!row) return;
+ const viewer = { kind: 'user', subject: req.principal.requester.slice(5), username: req.principal.username };
+ const result = await gallery.publish(ctx, row, viewer, req.body || {});
+ if (result.error) return problem(req, res, 422, 'pics.publication_invalid', result.error);
+ return res.json(pics.toWire(await store.imageById(s, row.id), { owner: true }));
+ });
+ r.put('/images/:slug/like', person, limits.budget('pics.image.write'), async (req, res) => {
+ const row = await load(req, res);
+ if (!row) return;
+ if (!gallery.isPublic(row)) return problem(req, res, 404, 'pics.not_found', 'No public picture.');
+ await s.db.query(`INSERT INTO pics_likes (image_id, actor) SELECT i.id, $2 FROM pics_images i WHERE i.id = $1 AND ${gallery.PUBLIC} ON CONFLICT DO NOTHING`, [row.id, whoOf(req).requester]);
+ return res.json(await gallery.counts(s, row.id));
+ });
+ r.delete('/images/:slug/like', person, limits.budget('pics.image.write'), async (req, res) => {
+ const row = await load(req, res);
+ if (!row || !gallery.isPublic(row)) return row ? problem(req, res, 404, 'pics.not_found', 'No public picture.') : undefined;
+ await s.db.query('DELETE FROM pics_likes WHERE image_id = $1 AND actor = $2', [row.id, whoOf(req).requester]);
+ return res.json(await gallery.counts(s, row.id));
+ });
+
// ── Upload: the raw picture is the body ─────────────────
// ── Upload: the raw picture is the body ─────────────────
const rawBody = express.raw({ type: () => true, limit: Math.max(cfg.maxImageBytes, cfg.anonymousMaxImageBytes) + 1 });
const rawBody = express.raw({ type: () => true, limit: Math.max(cfg.maxImageBytes, cfg.anonymousMaxImageBytes) + 1 });
r.post('/images', anyone, limits.budget('pics.image.upload'), (req, res, next) => rawBody(req, res, (err) => {
r.post('/images', anyone, limits.budget('pics.image.upload'), (req, res, next) => rawBody(req, res, (err) => {
@@ -92,10 +130,18 @@ function createPicsApi(ctx) {
@@ -92,10 +130,18 @@ function createPicsApi(ctx) {
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
+ r.get('/images/:slug/bytes', person, async (req, res) => {
+ const row = await load(req, res);
+ if (!row) return;
+ const got = await ctx.media.bytes(row.object_id);
+ if (!got.ok) return problem(req, res, 502, 'pics.storage', 'Picture storage is unavailable.');
+ res.set({ 'Content-Type': row.mime_type, 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox", 'Cache-Control': 'private, no-store' });
+ return res.send(Buffer.from(await got.res.arrayBuffer()));
+ });
+
r.get('/images/:slug', async (req, res) => {
r.get('/images/:slug', async (req, res) => {
const row = await load(req, res);
const row = await load(req, res);
if (!row) return undefined;
if (!row) return undefined;
- if (ctx.sharing && await ctx.sharing.shareOf('picture', row.id).catch(() => null) && !await ctx.sharing.canView(req.viewer, 'picture', row.id).catch(() => false)) return problem(req, res, 404, 'pics.image_not_found', 'No such picture.');
return res.json(pics.toWire(row, { owner: pics.isOwner(row, whoOf(req)) }));
return res.json(pics.toWire(row, { owner: pics.isOwner(row, whoOf(req)) }));
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+/**
+ * The public gallery: what OpenVibe.Pics shows when a picture's owner publishes it.
+ *
+ * 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
+ * narrows it (server/sharing.js clears the flag) or a moderator hides it.
+ */
+const { html, raw } = require('../render/html');
+const glyphs = require('openvibe-shared/glyphs');
+const labelsUi = require('../render/labels-ui');
+const PUBLIC = "i.listed = true AND i.state = 'active' AND i.audience = 'public' AND EXISTS (SELECT 1 FROM shares sh WHERE sh.service = 'mediahub' AND sh.kind = 'picture' AND sh.resource_id = i.id AND sh.audience = 'public')";
+const isPublic = (row) => Boolean(row.listed && row.state === 'active' && row.audience === 'public');
+const tags = (row) => { try { return JSON.parse(row.tags || '[]'); } catch { return []; } };
+function parseTags(value) {
+ if (!Array.isArray(value) || value.length > 10 || value.some((t) => typeof t !== 'string' || !/^[\p{L}\p{N}_-]{1,32}$/u.test(t))) return null;
+ return [...new Set(value.map((t) => t.toLowerCase()))];
+}
+/** The heading icon the showcase kit puts beside every section title (openvibe-shared/showcase keeps its own copy). */
+function headIcon(name) {
+ const key = glyphs.NAMES.includes(name) ? name : (glyphs.fromFa ? glyphs.fromFa(name) : null);
+ return key && glyphs.path(key) ? `<span class="sc-h-ic" aria-hidden="true">${glyphs.svg(key, { size: 18 })}</span>` : '';
+}
+function options(query = {}) {
+ return { sort: ['popular', 'new', 'rising'].includes(query.sort) ? query.sort : 'popular',
+ q: String(query.q || '').trim().slice(0, 120), tag: String(query.tag || '').trim().toLowerCase().slice(0, 32),
+ offset: Math.min(10000, Math.max(0, parseInt(query.cursor, 10) || 0)), limit: Math.min(30, Math.max(1, parseInt(query.limit, 10) || 30)) };
+}
+async function browse(s, query) {
+ const o = options(query);
+ const order = o.sort === 'new' ? 'published_at DESC, id DESC' : o.sort === 'rising' ? 'recent_likes DESC, published_at DESC, id DESC' : 'likes DESC, views DESC, published_at DESC, id DESC';
+ const rows = await s.db.many(`SELECT * FROM (SELECT i.*,
+ (SELECT count(*)::int FROM pics_likes l WHERE l.image_id = i.id) AS likes,
+ (SELECT count(*)::int FROM pics_views v WHERE v.image_id = i.id) AS views,
+ (SELECT count(*)::int FROM pics_likes l WHERE l.image_id = i.id AND l.created_at > $5::timestamptz) AS recent_likes
+ FROM pics_images i WHERE ${PUBLIC}
+ AND ($1 = '' OR position(lower($1) in lower(i.title || ' ' || i.description || ' ' || i.tags)) > 0)
+ AND ($2 = '' OR i.tags::jsonb @> jsonb_build_array($2::text))) g
+ ORDER BY ${order} LIMIT $3 OFFSET $4`, [o.q, o.tag, o.limit + 1, o.offset, new Date(s.now() - 86400000).toISOString()]);
+ return { rows: rows.slice(0, o.limit), next: rows.length > o.limit ? String(o.offset + o.limit) : null, ...o };
+}
+async function counts(s, id) {
+ return s.db.maybe(`SELECT (SELECT count(*)::int FROM pics_likes WHERE image_id = $1) AS likes,
+ (SELECT count(*)::int FROM pics_views WHERE image_id = $1) AS views`, [id]);
+}
+async function publish(ctx, row, viewer, input) {
+ if (typeof input.listed !== 'boolean') return { error: 'Choose whether to publish this picture.' };
+ const parsed = input.tags === undefined ? tags(row) : parseTags(input.tags);
+ if (!parsed) return { error: 'Use up to ten tags of 1–32 letters, numbers, underscores or hyphens.' };
+ if (input.listed && row.state !== 'active') return { error: 'Only an active picture can be published.' };
+ await ctx.s.tx(async () => {
+ if (input.listed) await ctx.sharing.setAudience(viewer, 'picture', row.id, 'public');
+ await ctx.s.db.query(`UPDATE pics_images SET listed = $2, tags = $3, author_name = $4,
+ published_at = CASE WHEN $2 THEN coalesce(published_at, $5::timestamptz) ELSE published_at END WHERE id = $1`,
+ [row.id, input.listed, JSON.stringify(parsed), String(viewer.displayName || viewer.username || 'OpenVibe member').slice(0, 100), ctx.s.iso()]);
+ });
+ return { ok: true };
+}
+const wire = (pics, row) => ({ ...pics.toWire(row), author: row.author_name || 'OpenVibe member', tags: tags(row), likes: Number(row.likes || 0), views: Number(row.views || 0), published_at: row.published_at });
+/** 1234 → "1.2K": counts on a card are read at a glance; the exact number is in the title and the screen-reader text. */
+const compact = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}K` : String(n)).replace('.0', '');
+const glyph = (name, size = 14) => raw(`<span class="ovp-gc-ic" aria-hidden="true">${glyphs.svg(name, { size })}</span>`);
+const count = (n, one, many, name) => html`<span class="ovp-gc-n" title="${n} ${n === 1 ? one : many}">${glyph(name)}<span aria-hidden="true">${compact(n)}</span><span class="ovp-sr">${n} ${n === 1 ? one : many}</span></span>`;
+// The picture is a link too (a second way in, out of the tab order); the title is the card's one link a keyboard meets.
+const card = (pics, row) => html`<article class="ovp-gallery-card"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner })}><a class="ovp-gc-pic" href="/i/${row.slug}" tabindex="-1"><img src="/i/${row.slug}/thumb.webp" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" loading="lazy" decoding="async"></a>
+<div class="ovp-gc-body"><h3 class="ovp-gc-title"><a href="/i/${row.slug}">${row.title || 'Untitled picture'}</a></h3>
+<p class="ovp-gc-meta"><span class="ovp-gc-by">${row.author_name || 'OpenVibe member'}</span>${count(Number(row.views || 0), 'view', 'views', 'eye')}${count(Number(row.likes || 0), 'like', 'likes', 'heart')}</p>
+${tags(row).length ? html`<p class="ovp-gc-tags">${tags(row).map((t) => html`<a class="ovp-tag" href="/?tag=${encodeURIComponent(t)}">#${t}</a>`)}</p>` : ''}</div></article>`;
+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>
+<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 ? html`<p class="ovp-gallery-empty">${result.q || result.tag ? 'No public pictures match yet. Try another search.' : 'The gallery is ready for its first public picture. Upload one, then choose Publish to gallery on its page.'}</p>` : ''}
+${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 };
@@ -104,7 +104,7 @@ ${render(o.body)}
@@ -104,7 +104,7 @@ ${render(o.body)}
}
}
// The site's own places in the navbar: upload, your pictures and your albums (those two ask you to sign in).
// The site's own places in the navbar: upload, your pictures and your albums (those two ask you to sign in).
-const NAV_LINKS = [{ label: 'Upload', href: '/' }, { label: 'Your pictures', href: '/mine' }, { label: 'Albums', href: '/albums' }];
+const NAV_LINKS = [{ label: 'Explore', href: '/' }, { label: 'Your pictures', href: '/mine' }, { label: 'Albums', href: '/albums' }];
// A signed-out copy is never reused without asking: Cloudflare drops `Vary` (it keeps only Accept-Encoding), so a
// A signed-out copy is never reused without asking: Cloudflare drops `Vary` (it keeps only Accept-Encoding), so a
// browser keyed the guest copy by its URL alone and, for five minutes after a quiet sign-in, showed the signed-out page
// browser keyed the guest copy by its URL alone and, for five minutes after a quiet sign-in, showed the signed-out page
@@ -40,6 +40,8 @@ const store = require('./store');
@@ -40,6 +40,8 @@ const store = require('./store');
const { createPicsApi } = require('./api');
const { createPicsApi } = require('./api');
const { createPicVariants } = require('./variants');
const { createPicVariants } = require('./variants');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const gallery = require('./gallery');
+const showcase = require('openvibe-shared/showcase');
const { PATHS: POLICY_PATHS, policyPage } = require('./policy');
const { PATHS: POLICY_PATHS, policyPage } = require('./policy');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -234,17 +236,26 @@ function createPicsRoutes(ctx) {
@@ -234,17 +236,26 @@ function createPicsRoutes(ctx) {
const sizeText = (b) => formatBytes(b).replace(/\.0 /, ' ');
const sizeText = (b) => formatBytes(b).replace(/\.0 /, ' ');
const homeFaq = () => [
const homeFaq = () => [
{ q: 'Is OpenVibe.Pics free?', a: `Yes. A free OpenVibe account can upload ${cfg.imagesPerDay} pictures a day, each up to ${sizeText(cfg.maxImageBytes)}, and keep up to ${sizeText(cfg.quotaBytes)}.` },
{ q: 'Is OpenVibe.Pics free?', a: `Yes. A free OpenVibe account can upload ${cfg.imagesPerDay} pictures a day, each up to ${sizeText(cfg.maxImageBytes)}, and keep up to ${sizeText(cfg.quotaBytes)}.` },
- { q: 'Who can see my pictures?', a: 'Only people you give the link to. Pictures are never listed, ranked or shown to search engines, and you can delete one at any time.' },
+ { 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: 'What is removed from my pictures?', a: 'Everything hidden in the file: the GPS position, the camera and the rest of its metadata. Your picture is decoded and saved again without any of it before anyone sees it, and the file you uploaded is not kept.' },
{ q: 'What is removed from my pictures?', a: 'Everything hidden in the file: the GPS position, the camera and the rest of its metadata. Your picture is decoded and saved again without any of it before anyone sees it, and the file you uploaded is not kept.' },
{ q: 'Which files can I upload?', a: `${FORMAT_NAMES}, animated GIF and WebP included. SVG and other formats are refused, so nothing is ever served as it was uploaded.` },
{ q: 'Which files can I upload?', a: `${FORMAT_NAMES}, animated GIF and WebP included. SVG and other formats are refused, so nothing is ever served as it was uploaded.` },
{ q: 'Can I get a different size or format?', a: `Yes. Every picture has links at ${variants.LADDER.join(', ')} pixels wide as WebP, AVIF, JPEG or PNG, made when first asked for.` },
{ q: 'Can I get a different size or format?', a: `Yes. Every picture has links at ${variants.LADDER.join(', ')} pixels wide as WebP, AVIF, JPEG or PNG, made when first asked for.` },
{ q: 'What is not allowed?', a: 'Anything illegal, sexual content shared without consent, harassment, other people\'s private information, and spam. Every picture has a Report form, a child-safety report hides it at once, and copyright owners can use the DMCA page.' },
{ q: 'What is not allowed?', a: 'Anything illegal, sexual content shared without consent, harassment, other people\'s private information, and spam. Every picture has a Report form, a child-safety report hides it at once, and copyright owners can use the DMCA page.' },
];
];
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
+ const discovery = await gallery.browse(s, req.query);
const mine = signedIn(req) ? (await store.imagesOf(s, whoOf(req).requester, { limit: 12 })).rows : [];
const mine = signedIn(req) ? (await store.imagesOf(s, whoOf(req).requester, { limit: 12 })).rows : [];
const canUpload = signedIn(req) || cfg.anonymous;
const canUpload = signedIn(req) || cfg.anonymous;
- const description = 'Upload a picture and get a link to share or embed anywhere: albums, any size or format, and location and camera details removed from every picture.';
+ const description = 'Upload a picture and get a link to share or embed anywhere: a gallery of what people chose to publish, albums, any size or format, and location and camera details removed from every picture.';
const faq = homeFaq();
const faq = homeFaq();
+ // The curl sample keeps its line breaks: a backslash and a newline, so it can be copied and run as it is.
+ const curl = ['curl -X POST ' + base + '/api/v1/images \\', ' -H "Authorization: Bearer $OPENVIBE_TOKEN" \\', ' -H "Content-Type: image/jpeg" \\', ' --data-binary @photo.jpg'].join('\n');
+ const limitColumns = ['Signed in', ...(cfg.anonymous ? ['Without an account'] : [])];
+ const limitRows = [
+ { label: 'Largest picture', values: [sizeText(cfg.maxImageBytes), ...(cfg.anonymous ? [sizeText(cfg.anonymousMaxImageBytes)] : [])] },
+ { label: 'Pictures a day', values: [String(cfg.imagesPerDay), ...(cfg.anonymous ? [String(cfg.anonymousPerDay)] : [])] },
+ { label: 'Storage', values: [sizeText(cfg.quotaBytes), ...(cfg.anonymous ? ['Kept ' + cfg.anonymousIdleDays + ' days after the last view'] : [])] },
+ ];
return page(req, res, {
return page(req, res, {
index: true,
index: true,
title: `${SITE_NAME}: share and embed pictures with a link`,
title: `${SITE_NAME}: share and embed pictures with a link`,
@@ -252,62 +263,76 @@ function createPicsRoutes(ctx) {
@@ -252,62 +263,76 @@ function createPicsRoutes(ctx) {
jsonLd: [
jsonLd: [
seo.jsonLd.website({ name: SITE_NAME, url: base, description }),
seo.jsonLd.website({ name: SITE_NAME, url: base, description }),
seo.jsonLd.organization(),
seo.jsonLd.organization(),
- seo.jsonLd.softwareApp({ name: SITE_NAME, url: base, description, category: 'MultimediaApplication', keywords: 'image hosting, picture sharing, embed images, resize images, convert to WebP, alt text' }),
+ seo.jsonLd.softwareApp({ name: SITE_NAME, url: base, description, category: 'MultimediaApplication', keywords: 'image hosting, picture sharing, public picture gallery, embed images, resize images, convert to WebP, alt text' }),
seo.jsonLd.webPage({ name: SITE_NAME, url: `${base}/`, description, siteUrl: base }),
seo.jsonLd.webPage({ name: SITE_NAME, url: `${base}/`, description, siteUrl: base }),
seo.jsonLd.faq(faq),
seo.jsonLd.faq(faq),
],
],
- scripts: canUpload ? ['js/pics.js'] : [],
- body: html`<header class="ovp-hero"><p class="ovp-eyebrow">${SITE_NAME}</p>
-<h1>Share pictures. <span class="ovp-accent">Fast, open, yours.</span></h1>
-<p class="ovp-lede">Drop a picture, get a link to share or embed anywhere: on a forum, in a chat, on your site. No feed to scroll, no counts to chase, and nothing about you hidden in the file.</p></header>
-${canUpload ? uploadForm(req) : html`<section class="ovp-drop 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>`}
+ styles: ['kit.css', 'showcase.css'],
+ scripts: ['js/pics.js', 'js/pics-gallery.js'],
+ body: html`${raw(showcase.hero({
+ title: 'Share pictures.', accent: 'Discover something new.',
+ 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>`}
+${raw(gallery.render(pics, discovery))}
${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><div class="ovp-section-head"><h2 id="h-latest">Your latest pictures</h2><a href="/mine">All of them</a></div>${grid(mine, 'Nothing yet: your uploads show up here.')}</section>` : ''}
${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><div class="ovp-section-head"><h2 id="h-latest">Your latest pictures</h2><a href="/mine">All of them</a></div>${grid(mine, 'Nothing yet: your uploads show up here.')}</section>` : ''}
<p class="ovp-note">The hidden data is removed by us: your picture arrives with its GPS position, its camera and the time it was taken, and we take all of it out before anyone sees the picture. <a href="/exif">How that works</a>.</p>
<p class="ovp-note">The hidden data is removed by us: your picture arrives with its GPS position, its camera and the time it was taken, and we take all of it out before anyone sees the picture. <a href="/exif">How that works</a>.</p>
-<section class="ovp-section" aria-labelledby="h-how"><h2 id="h-how">How it works</h2><ol class="ovp-steps">
-<li><h3>Drop a picture</h3><p>Choose, drag or paste ${FORMAT_NAMES}, up to ${sizeText(cfg.maxImageBytes)}. Several at once works too.</p></li>
-<li><h3>Get your links</h3><p>A page to share, a direct link, and Markdown, BBCode and HTML ready to copy.</p></li>
-<li><h3>Use it anywhere</h3><p>Post it in a forum or a chat, embed it on your site, or put it in an album. Delete it whenever you like.</p></li>
-</ol></section>
-<section class="ovp-section ovp-features" aria-label="What you get">
-<div class="ovp-feature"><h3>Links that work everywhere</h3><p>A page to share, a direct link for <code><img></code>, and Markdown, BBCode and HTML ready to copy.</p></div>
-<div class="ovp-feature"><h3>Unlisted by default</h3><p>Only people with the link see a picture. Nothing is listed, ranked or indexed.</p></div>
-<div class="ovp-feature"><h3>Clean files</h3><p>Every picture is re-encoded, and its GPS position, camera serial and other hidden details are removed before anyone sees it.</p></div>
-<div class="ovp-feature"><h3>Albums</h3><p>Put pictures together and share the album with one link.</p></div>
-<div class="ovp-feature"><h3>Any size, any format</h3><p>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.</p></div>
-<div class="ovp-feature"><h3>Alt text</h3><p>Say what a picture shows for people who cannot see it, or let AI draft it when you ask. It goes into every embed snippet.</p></div>
-</section>
-<section class="ovp-section" aria-labelledby="h-links"><h2 id="h-links">The links a picture gets</h2>
-<p class="ovp-section-lede">Each picture has a short name of eight letters and digits. These are the links it answers at:</p>
-<dl class="ovp-links">
-<div><dt>Its page</dt><dd><code>${base}/i/<name></code></dd></div>
-<div><dt>The picture itself, in the format you uploaded</dt><dd><code>${base}/i/<name>.jpg</code></dd></div>
-<div><dt>Another width or format</dt><dd><code>${base}/i/<name>/w/640.avif</code></dd></div>
-<div><dt>Markdown, with your alt text</dt><dd><code></code></dd></div>
-</dl></section>
-<section class="ovp-section" aria-labelledby="h-api"><h2 id="h-api">Upload from your own app</h2>
-<p class="ovp-section-lede">The API takes the picture as the request body, with an OpenVibe sign-in token, and answers with its links.</p>
-<pre class="ovp-code" tabindex="0" aria-label="Upload with curl"><code>curl -X POST ${base}/api/v1/images \\
- -H "Authorization: Bearer $OPENVIBE_TOKEN" \\
- -H "Content-Type: image/jpeg" \\
- --data-binary @photo.jpg</code></pre></section>
-<section class="ovp-section" aria-labelledby="h-limits"><h2 id="h-limits">What a free account gets</h2>
-<div class="ovp-table-wrap" tabindex="0" role="region" aria-labelledby="h-limits"><table class="ovp-size-table ovp-limits"><thead><tr><th scope="col">Limit</th><th scope="col">Signed in</th>${cfg.anonymous ? html`<th scope="col">Without an account</th>` : ''}</tr></thead><tbody>
-<tr><th scope="row">Largest picture</th><td>${sizeText(cfg.maxImageBytes)}</td>${cfg.anonymous ? html`<td>${sizeText(cfg.anonymousMaxImageBytes)}</td>` : ''}</tr>
-<tr><th scope="row">Pictures a day</th><td>${cfg.imagesPerDay}</td>${cfg.anonymous ? html`<td>${cfg.anonymousPerDay}</td>` : ''}</tr>
-<tr><th scope="row">Storage</th><td>${sizeText(cfg.quotaBytes)}</td>${cfg.anonymous ? html`<td>Kept ${cfg.anonymousIdleDays} days after the last view</td>` : ''}</tr>
-</tbody></table></div></section>
-<section class="ovp-section" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2><div class="faq">${faq.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</div></section>
-<section class="ovp-section" aria-labelledby="h-policies"><h2 id="h-policies">What we promise, in writing</h2><div class="ovp-features">
-<div class="ovp-feature"><h3><a href="/exif">What happens to your picture</a></h3><p>The metadata — the GPS position, the camera, the time it was taken — is taken out by us before anyone sees the picture, and the file you uploaded is not kept.</p></div>
-<div class="ovp-feature"><h3><a href="/content-policy">What you may put here</a></h3><p>What is not allowed, how a rule is enforced, and what this site does not do: no feed, no counts, no adverts.</p></div>
-<div class="ovp-feature"><h3><a href="/ai-training">Pictures and AI</a></h3><p>We don't train AI on your content. If that ever changes, it'll be your choice, and it starts off.</p></div>
-</div></section>
-<section class="ovp-section" aria-labelledby="h-more"><h2 id="h-more">More on OpenVibe</h2><ul class="ovp-more" role="list">
-<li><a href="https://openvibe.chat/"><strong>OpenVibe.Chat</strong><span>Send pictures in rooms and servers: they go up to OpenVibe.Pics.</span></a></li>
-<li><a href="${config.video.url}/"><strong>OpenVibe.Video</strong><span>Upload videos with AI captions and chapters.</span></a></li>
-<li><a href="${config.baseUrl}/"><strong>OpenVibe.Download</strong><span>A private drive for every other kind of file.</span></a></li>
-<li><a href="https://openvibe.community/"><strong>OpenVibe.Community</strong><span>Forums and pastes to post your pictures in.</span></a></li>
-</ul></section>`,
+${raw(showcase.steps({
+ title: 'How it works',
+ 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: '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.' },
+ ],
+}))}
+${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: '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.` },
+ { icon: 'text', title: 'Alt text', text: 'Say what a picture shows for people who cannot see it, or let AI draft it when you ask. It goes into every embed snippet.' },
+ ],
+}))}
+${raw(showcase.features({
+ title: 'The links a picture gets',
+ lede: 'Each picture has a short name of eight letters and digits. These are the links it answers at:',
+ items: [
+ { icon: 'file', title: 'Its page', text: `${base}/i/<name>` },
+ { icon: 'image', title: 'The picture itself, in the format you uploaded', text: `${base}/i/<name>.jpg` },
+ { icon: 'sliders', title: 'Another width or format', text: `${base}/i/<name>/w/640.avif` },
+ { icon: 'code', title: 'Markdown, with your alt text', text: `` },
+ ],
+}))}
+${raw(showcase.code({
+ title: 'Upload from your own app',
+ lede: 'The API takes the picture as the request body, with an OpenVibe sign-in token, and answers with its links.',
+ samples: [{ label: 'curl', lang: 'bash', code: curl }],
+}))}
+${raw(showcase.limits({ title: 'What a free account gets', columns: limitColumns, rows: limitRows }))}
+${raw(showcase.faq({ title: 'Questions', items: faq.map((f) => [f.q, f.a]) }))}
+${raw(showcase.features({
+ title: 'What we promise, in writing',
+ items: [
+ { icon: 'shield', title: 'What happens to your picture', href: '/exif', text: 'The metadata — the GPS position, the camera, the time it was taken — is taken out by us before anyone sees the picture, and the file you uploaded is not kept.' },
+ { icon: 'book', title: 'What you may put here', href: '/content-policy', text: 'What is not allowed, how a rule is enforced, and what this site does not do: opt-in discovery, public counts, and no adverts.' },
+ { icon: 'brain', title: 'Pictures and AI', href: '/ai-training', text: "We don't train AI on your content. If that ever changes, it'll be your choice, and it starts off." },
+ ],
+}))}
+${raw(showcase.network({
+ service: 'pics',
+ items: [
+ { to: 'cam', href: 'https://openvibe.cam', what: 'Take a photo and save it here' },
+ { to: 'studio', href: 'https://openvibe.studio', what: 'Edit your pictures with AI' },
+ { to: 'community', href: 'https://openvibe.community', what: 'Share pictures in pastes and discussions' },
+ { to: 'meme', href: 'https://openvibe.meme', what: 'Make a meme from a picture' },
+ ],
+}))}
+<script>${raw(showcase.NETWORK_JS)}</script>`,
});
});
});
});
@@ -353,6 +378,7 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
@@ -353,6 +378,7 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
const who = whoOf(req);
const who = whoOf(req);
const owner = pics.isOwner(row, who);
const owner = pics.isOwner(row, who);
const L = pics.links(row);
const L = pics.links(row);
+ if (req.method === 'GET' && gallery.isPublic(row) && !owner) await s.db.query(`INSERT INTO pics_views (image_id, visitor, day) SELECT i.id, $2, $3 FROM pics_images i WHERE i.id = $1 AND ${gallery.PUBLIC} ON CONFLICT DO NOTHING`, [row.id, pics.addressId(req.ip), s.iso().slice(0, 10)]);
const albums = owner ? await store.albumsOf(s, who.requester) : [];
const albums = owner ? await store.albumsOf(s, who.requester) : [];
const holding = owner ? await store.albumsHolding(s, row.id) : [];
const holding = owner ? await store.albumsHolding(s, row.id) : [];
const title = row.title || 'Untitled picture';
const title = row.title || 'Untitled picture';
@@ -361,10 +387,11 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
@@ -361,10 +387,11 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
const likeRow = owner && /^[A-Za-z0-9]{4,16}$/.test(String(req.query.like || '')) ? await store.imageBySlug(s, String(req.query.like)) : null;
const likeRow = owner && /^[A-Za-z0-9]{4,16}$/.test(String(req.query.like || '')) ? await store.imageBySlug(s, String(req.query.like)) : null;
const likeSlug = likeRow && likeRow.id !== row.id && likeRow.owner === row.owner ? likeRow.slug : null;
const likeSlug = likeRow && likeRow.id !== row.id && likeRow.owner === row.owner ? likeRow.slug : null;
return page(req, res, {
return page(req, res, {
- title, noindex: true,
+ title, noindex: !gallery.isPublic(row), index: gallery.isPublic(row),
+ jsonLd: gallery.isPublic(row) ? { '@context': 'https://schema.org', '@type': 'ImageObject', name: title, contentUrl: L.direct, url: L.page, caption: row.description || title, author: { '@type': 'Person', name: row.author_name || 'OpenVibe member' } } : undefined,
description: row.description || `${title}: a picture on ${SITE_NAME}.`,
description: row.description || `${title}: a picture on ${SITE_NAME}.`,
image: hidden ? undefined : L.direct, ogType: 'article',
image: hidden ? undefined : L.direct, ogType: 'article',
- scripts: ['js/pics.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],
+ scripts: ['js/pics.js', 'js/pics-gallery.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],
...(owner ? { localStyles: ['css/labels.css'] } : {}),
...(owner ? { localStyles: ['css/labels.css'] } : {}),
private: deleteKey ? true : undefined,
private: deleteKey ? true : undefined,
body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}${owner && req.query.again ? notice('You had already uploaded this picture: here it is. Nothing was stored twice.', 'info') : ''}${owner && req.query.new && likeSlug ? notice(html`It looks like a picture you uploaded before: <a href="/i/${likeSlug}">that one</a>. If you meant to keep only one, delete the other.`, 'info') : ''}
body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}${owner && req.query.again ? notice('You had already uploaded this picture: here it is. Nothing was stored twice.', 'info') : ''}${owner && req.query.new && likeSlug ? notice(html`It looks like a picture you uploaded before: <a href="/i/${likeSlug}">that one</a>. If you meant to keep only one, delete the other.`, 'info') : ''}
@@ -376,11 +403,14 @@ ${labelsUi.chips(labelsUi.inForce(row))}
@@ -376,11 +403,14 @@ ${labelsUi.chips(labelsUi.inForce(row))}
<figure class="ovp-figure"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner, opened: true })}><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>
<figure class="ovp-figure"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner, opened: true })}><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>
<p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>
<p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>
${raw(shareButton({ url: L.page, title, text: row.description || `${title}: a picture on ${SITE_NAME}.`, image: L.direct, kind: 'picture', id: row.id }))}
${raw(shareButton({ url: L.page, title, text: row.description || `${title}: a picture on ${SITE_NAME}.`, image: L.direct, kind: 'picture', id: row.id }))}
+${gallery.isPublic(row) ? html`<p>${row.author_name || 'OpenVibe member'} · ${(await gallery.counts(s, row.id)).views} views · <span data-like-count>${(await gallery.counts(s, row.id)).likes}</span> likes</p><button class="ovp-btn" data-like="${row.slug}">Like</button><p role="status" data-like-status></p>` : ''}
+${!hidden ? html`<p class="ovp-gallery-tabs"><a class="ovp-btn" href="https://openvibe.studio/image-tools?picture=${row.slug}">Edit with AI</a><a class="ovp-btn" href="https://openvibe.meme/make?picture=${row.slug}">Make a meme</a><a class="ovp-btn" href="https://openvibe.cam/">Take a photo</a></p>` : ''}
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}
${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}
</article>
</article>
+${gallery.isPublic(row) ? html`<section class="ovp-section"><h2>Comments</h2><button class="ovp-btn" data-comments="${row.id}">Show Community discussion</button><div data-comments-list aria-live="polite"></div></section>` : ''}
<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>
<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>
-<p class="ovp-share-lede">No account needed: anyone with the link can open this picture and copy these. The direct link is the picture itself, at full size — no page around it.</p>
+<p class="ovp-share-lede">These links follow the audience you choose in Share. Private pictures are visible only to you. The direct link is the picture itself, at full size — no page around it.</p>
<div class="ovp-link-grid">
<div class="ovp-link-grid">
${linkCard('Direct link', 'The picture itself, full size.', L.direct, `snip-direct-${row.slug}`)}
${linkCard('Direct link', 'The picture itself, full size.', L.direct, `snip-direct-${row.slug}`)}
${linkCard('Markdown', 'For a chat or a post.', L.markdown, `snip-md-${row.slug}`)}
${linkCard('Markdown', 'For a chat or a post.', L.markdown, `snip-md-${row.slug}`)}
@@ -395,6 +425,7 @@ ${!hidden && variants.widthsFor(row).length ? html`<section class="ovp-section o
@@ -395,6 +425,7 @@ ${!hidden && variants.widthsFor(row).length ? html`<section class="ovp-section o
${[['webp', 'WebP'], ['avif', 'AVIF'], ['jpg', 'JPEG'], ['png', 'PNG']].map(([fmt, label]) => html`<tr><th scope="row">${label}</th>${variants.widthsFor(row).map((w) => html`<td><a href="/i/${row.slug}/w/${w}.${fmt}" rel="nofollow">${w}</a></td>`)}</tr>`)}
${[['webp', 'WebP'], ['avif', 'AVIF'], ['jpg', 'JPEG'], ['png', 'PNG']].map(([fmt, label]) => html`<tr><th scope="row">${label}</th>${variants.widthsFor(row).map((w) => html`<td><a href="/i/${row.slug}/w/${w}.${fmt}" rel="nofollow">${w}</a></td>`)}</tr>`)}
</tbody></table></section>` : ''}
</tbody></table></section>` : ''}
${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-yours"><h2 id="h-yours">Yours</h2>
${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-yours"><h2 id="h-yours">Yours</h2>
+<form class="ovp-form" method="post" action="/i/${row.slug}/publication"><label for="p-tags">Tags (comma separated)</label><input id="p-tags" name="tags" maxlength="330" value="${gallery.tags(row).join(', ')}"><p>Publishing makes this picture visible to everyone, lists it in the gallery, enables public counts, and lets search engines index it.</p><button class="ovp-btn" name="listed" value="${!row.listed}">${row.listed ? 'Remove from gallery' : 'Publish to gallery'}</button><p>Removing from the gallery keeps your sharing setting. Use Share to make it private again.</p></form>
<form class="ovp-form" method="post" action="/i/${row.slug}/edit">
<form class="ovp-form" method="post" action="/i/${row.slug}/edit">
<label for="e-title">Title</label><input id="e-title" name="title" type="text" maxlength="120" value="${row.title}">
<label for="e-title">Title</label><input id="e-title" name="title" type="text" maxlength="120" value="${row.title}">
<label for="e-desc">Description</label><textarea id="e-desc" name="description" rows="3" maxlength="2000">${row.description}</textarea>
<label for="e-desc">Description</label><textarea id="e-desc" name="description" rows="3" maxlength="2000">${row.description}</textarea>
@@ -458,6 +489,15 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
@@ -458,6 +489,15 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
return res.redirect(303, `/i/${row.slug}`);
return res.redirect(303, `/i/${row.slug}`);
});
});
+ r.post('/i/:slug/publication', mustSignIn('Sign in to publish your picture.'), budgeted('pics.image.write'), async (req, res) => {
+ if (!sameSite(req, res, `/i/${req.params.slug}`)) return;
+ const row = await ownPicture(req, res);
+ if (!row) return;
+ const out = await gallery.publish(ctx, row, req.viewer, { listed: req.body.listed === 'true', tags: String(req.body.tags || '').split(',').map((v) => v.trim()).filter(Boolean) });
+ if (out.error) return picturePage(req, res, row, { message: out.error, tone: 'warn' }, 422);
+ return res.redirect(303, `/i/${row.slug}`);
+ });
+
r.post('/i/:slug/describe', mustSignIn('Sign in to change your picture.'), budgeted('pics.image.describe'), async (req, res) => {
r.post('/i/:slug/describe', mustSignIn('Sign in to change your picture.'), budgeted('pics.image.describe'), async (req, res) => {
if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
const row = await ownPicture(req, res);
const row = await ownPicture(req, res);
@@ -659,10 +699,10 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
@@ -659,10 +699,10 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
// ── Discovery, legal, update log ────────────────────────
// ── Discovery, legal, update log ────────────────────────
r.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
r.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
- .send(`# openvibe.pics: pictures are unlisted; only the home page is for search engines.\n${seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`], disallow: ['/i/', '/a/', '/mine', '/albums', '/staff', '/api/', '/auth/', '/upload'] })}`));
+ .send(`# openvibe.pics: only explicitly published pictures are indexed.\n${seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`], disallow: ['/a/', '/mine', '/albums', '/staff', '/api/', '/auth/', '/upload'] })}`));
// The indexable pages: the home, the update log, the rules and the policy pages. No picture or album is ever listed.
// The indexable pages: the home, the update log, the rules and the policy pages. No picture or album is ever listed.
- r.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
- .send(seo.sitemapXml(['/', '/updates', ...legal.PATHS, ...POLICY_PATHS].map((p) => ({ loc: `${base}${p}` })))));
+ r.get('/sitemap.xml', async (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
+ .send(seo.sitemapXml(['/', '/updates', ...legal.PATHS, ...POLICY_PATHS].map((p) => ({ loc: `${base}${p}` })).concat((await s.db.many(`SELECT i.slug FROM pics_images i WHERE ${gallery.PUBLIC} ORDER BY i.published_at DESC LIMIT 50000`)).map((r) => ({ loc: `${base}/i/${r.slug}` }))))));
// RFC 9116, canonical to this host (openvibe.pics).
// RFC 9116, canonical to this host (openvibe.pics).
r.get('/.well-known/security.txt', securityTxt.handler(base));
r.get('/.well-known/security.txt', securityTxt.handler(base));
// The site as llms.txt describes it, shared by /llms.txt and /llms-full.txt (SEO-33). AGT-5: its own pages are
// The site as llms.txt describes it, shared by /llms.txt and /llms-full.txt (SEO-33). AGT-5: its own pages are
@@ -672,7 +712,7 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
@@ -672,7 +712,7 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
markdown: true,
markdown: true,
base,
base,
summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, any size or format.',
summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, any size or format.',
- details: 'Pictures are unlisted (reachable only by their link). 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 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).',
sections: [
sections: [
{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }] },
{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }] },
],
],
@@ -683,7 +723,7 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
@@ -683,7 +723,7 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
{ title: 'Pictures and AI', url: `${base}/ai-training` },
{ title: 'Pictures and AI', url: `${base}/ai-training` },
{ title: 'Terms', url: `${base}/terms` },
{ title: 'Terms', url: `${base}/terms` },
{ title: 'DMCA', url: `${base}/dmca` },
{ title: 'DMCA', url: `${base}/dmca` },
- { title: 'Sitemap', url: `${base}/sitemap.xml`, note: 'only the home, the update log and the policy pages: no picture or album is ever listed' },
+ { title: 'Sitemap', url: `${base}/sitemap.xml`, note: 'home, policies and explicitly published pictures' },
{ title: 'Full text for language models', url: `${base}/llms-full.txt` },
{ title: 'Full text for language models', url: `${base}/llms-full.txt` },
],
],
});
});
@@ -122,8 +122,7 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {
@@ -122,8 +122,7 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {
sub: 'The content policy, and what happens when a rule is broken',
sub: 'The content policy, and what happens when a rule is broken',
back: { href: '/', label: 'Home' },
back: { href: '/', label: 'Home' },
}),
}),
- sec('The short answer', pm('OpenVibe.Pics is a link, not a gallery. A picture is reachable by anyone who has its link and by nobody else, so what you put here is what you are willing to share with the people you send it to. '
- + 'Most of the site works without an account, and nothing you upload is listed, ranked or shown to a search engine.')),
+ sec('The short answer', pm('A picture is unlisted to begin with: anyone with the link can open it and nobody else. You choose whether to keep it that way, narrow it to specific people, or publish it to the public gallery. Only explicitly published pictures appear in discovery, search engines and public engagement counts.')),
sec('Not allowed here', rows(contentRules(reasons, reasonText).map(([title, sub]) => kit.row({ title, sub }))), 'These are the reasons a picture can be reported for. Each one is a rule, not a suggestion.'),
sec('Not allowed here', rows(contentRules(reasons, reasonText).map(([title, sub]) => kit.row({ title, sub }))), 'These are the reasons a picture can be reported for. Each one is a rule, not a suggestion.'),
sec('How a rule is enforced', rows([
sec('How a rule is enforced', rows([
kit.row({ title: 'Every picture has a report form', sub: 'Whoever has the link can report it with one of the reasons above. One report per person, so a single reader cannot hide a picture on their own.' }),
kit.row({ title: 'Every picture has a report form', sub: 'Whoever has the link can report it with one of the reasons above. One report per person, so a single reader cannot hide a picture on their own.' }),
@@ -133,8 +132,8 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {
@@ -133,8 +132,8 @@ function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {
kit.row({ title: 'You can delete your own, at any time', sub: 'Every picture you own has a delete button, and an album can be deleted in one action. The picture is gone from this site; copies other sites already loaded can show for up to ten minutes.' }),
kit.row({ title: 'You can delete your own, at any time', sub: 'Every picture you own has a delete button, and an album can be deleted in one action. The picture is gone from this site; copies other sites already loaded can show for up to ten minutes.' }),
])),
])),
sec('What this site does not do', rows([
sec('What this site does not do', rows([
- kit.row({ title: 'No feed to scroll', sub: 'There is no list of other people\'s pictures, no ranking, and no gallery of what was uploaded today.' }),
- kit.row({ title: 'No counts to chase', sub: 'No likes, no followers, no numbers of how a picture is doing. There is nothing to count, because nothing is ranked.' }),
+ kit.row({ title: 'Public sharing is opt-in', sub: 'Publish a picture to include it in Popular, New and Rising. Remove it from the gallery whenever you choose.' }),
+ kit.row({ title: 'Counts on public pictures only', sub: 'Published pictures show likes and daily deduplicated views. Private and link-only pictures have no public counts.' }),
kit.row({ title: 'No adverts, nothing sold', sub: 'No advertising trackers, and your pictures are not sold or used for anyone else\'s advertising.' }),
kit.row({ title: 'No adverts, nothing sold', sub: 'No advertising trackers, and your pictures are not sold or used for anyone else\'s advertising.' }),
kit.row({ title: 'No app to install', sub: 'Every picture and album opens in a browser, signed in or not. There is no app and no account gate in front of the link.' }),
kit.row({ title: 'No app to install', sub: 'Every picture and album opens in a browser, signed in or not. There is no app and no account gate in front of the link.' }),
])),
])),
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+const express = require('express');
+const crypto = require('crypto');
+const { asyncRouter } = require('../http/router');
+const images = require('./store');
+function resumable(ctx, { person, problem, whoOf }) {
+ const r = asyncRouter(); const { s, pics, config, limits } = ctx;
+ const wire = (u) => ({ id: u.id, size: u.size, offset: u.offset_bytes, expires_at: u.expires_at, image_id: u.image_id });
+ r.use(person);
+ 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.');
+ 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()]);
+ });
+ 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));
+ });
+ async function own(req, lock = false) {
+ return s.db.maybe(`SELECT * FROM pics_uploads WHERE id = $1 AND owner = $2 AND expires_at > $3 ${lock ? 'FOR UPDATE' : ''}`, [req.params.id, whoOf(req).requester, s.iso()]);
+ }
+ r.get('/:id', async (req, res) => { const u = await own(req); return u ? res.json(wire(u)) : problem(req, res, 404, 'pics.upload_not_found', 'No such upload.'); });
+ r.patch('/:id', limits.budget('pics.image.write'), express.raw({ type: () => true, limit: '1mb' }), async (req, res) => s.tx(async () => {
+ const u = await own(req, true);
+ if (!u) return problem(req, res, 404, 'pics.upload_not_found', 'No such upload.');
+ const offset = Number(req.get('upload-offset'));
+ if (!req.get('upload-offset') || !Number.isInteger(offset) || offset !== u.offset_bytes || u.image_id) return problem(req, res, 409, 'pics.upload_offset', 'Read the upload status and resume at its offset.');
+ if (!Buffer.isBuffer(req.body) || !req.body.length || offset + req.body.length > u.size) return problem(req, res, 422, 'pics.upload_size', 'The chunk must fit the declared upload size.');
+ await s.db.query('INSERT INTO pics_upload_chunks (upload_id, offset_bytes, bytes) VALUES ($1,$2,$3)', [u.id, offset, req.body]);
+ await s.db.query('UPDATE pics_uploads SET offset_bytes = $2 WHERE id = $1', [u.id, offset + req.body.length]);
+ return res.json(wire({ ...u, offset_bytes: offset + req.body.length }));
+ }));
+ r.post('/:id/complete', limits.budget('pics.image.upload'), async (req, res) => s.tx(async () => {
+ const u = await own(req, true);
+ if (!u) return problem(req, res, 404, 'pics.upload_not_found', 'No such upload.');
+ 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 });
+ 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]);
+ return res.status(201).json(pics.toWire(out.image, { owner: true }));
+ }));
+ r.delete('/:id', async (req, res) => { await s.db.query('DELETE FROM pics_uploads WHERE id = $1 AND owner = $2', [req.params.id, whoOf(req).requester]); return res.status(204).end(); });
+ return r;
+}
+module.exports = { resumable };
@@ -136,8 +136,9 @@ function createPics({ s, media, config, log = console, events = null, sharing =
@@ -136,8 +136,9 @@ function createPics({ s, media, config, log = console, events = null, sharing =
});
});
if (events && !anonymous) await events.pictureUploaded(r);
if (events && !anonymous) await events.pictureUploaded(r);
return r; });
return r; });
- // Everyone by default: a picture has always been reachable by its link. The person can narrow it in the sheet.
- if (sharing) await sharing.register(row.owner, 'picture', row.id, 'public');
+ // 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');
return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}), ...(similar ? { similar: { id: similar.id, slug: similar.slug } } : {}) };
return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}), ...(similar ? { similar: { id: similar.id, slug: similar.slug } } : {}) };
}
}
@@ -261,6 +262,7 @@ function createPics({ s, media, config, log = console, events = null, sharing =
@@ -261,6 +262,7 @@ function createPics({ s, media, config, log = console, events = null, sharing =
ai: row.ai_described_at ? { caption: row.ai_caption || null, tags: tagsOf(row), described_at: row.ai_described_at } : null,
ai: row.ai_described_at ? { caption: row.ai_caption || null, tags: tagsOf(row), described_at: row.ai_described_at } : null,
// L1 labels: `{ labels, suggested }` (common.content-labels@1). A suggestion is shown, never applied.
// L1 labels: `{ labels, suggested }` (common.content-labels@1). A suggestion is shown, never applied.
content_labels: contentLabels.labelsOf(row),
content_labels: contentLabels.labelsOf(row),
+ listed: Boolean(row.listed), tags: JSON.parse(row.tags || '[]'),
state: row.state, created_at: row.created_at, links: links(row),
state: row.state, created_at: row.created_at, links: links(row),
...(owner ? { report_count: Number(row.report_count) } : {}),
...(owner ? { report_count: Number(row.report_count) } : {}),
});
});
@@ -95,7 +95,9 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
@@ -95,7 +95,9 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
if (ref.kind === 'picture' || ref.kind === 'album') {
if (ref.kind === 'picture' || ref.kind === 'album') {
const table = ref.kind === 'picture' ? 'pics_images' : 'pics_albums';
const table = ref.kind === 'picture' ? 'pics_images' : 'pics_albums';
await tx.exec(`UPDATE ${table} SET audience = $1 WHERE id = $2`, [audience, ref.id]);
await tx.exec(`UPDATE ${table} SET audience = $1 WHERE id = $2`, [audience, ref.id]);
+ if (ref.kind === 'picture' && audience !== 'public') await tx.exec('UPDATE pics_images SET listed = false WHERE id = $1', [ref.id]);
if (ref.kind === 'album') {
if (ref.kind === 'album') {
+ if (audience !== 'public') await tx.exec('UPDATE pics_images SET listed = false WHERE id IN (SELECT image_id FROM pics_album_images WHERE album_id = $1)', [ref.id]);
await tx.exec('UPDATE pics_images SET audience = $1 WHERE id IN (SELECT image_id FROM pics_album_images WHERE album_id = $2)', [audience, ref.id]);
await tx.exec('UPDATE pics_images SET audience = $1 WHERE id IN (SELECT image_id FROM pics_album_images WHERE album_id = $2)', [audience, ref.id]);
await tx.exec("UPDATE shares SET audience = $1, revision = revision + 1 WHERE service = $2 AND kind = 'picture' AND resource_id IN (SELECT image_id FROM pics_album_images WHERE album_id = $3)", [audience, SERVICE, ref.id]);
await tx.exec("UPDATE shares SET audience = $1, revision = revision + 1 WHERE service = $2 AND kind = 'picture' AND resource_id IN (SELECT image_id FROM pics_album_images WHERE album_id = $3)", [audience, SERVICE, ref.id]);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+const assert = require('assert');
+const sharp = require('sharp');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+function hostClient(t) {
+ return (p, o = {}) => new Promise((resolve, reject) => {
+ const headers = { Host: 'openvibe.pics', ...(o.headers || {}) };
+ if (o.as) headers.cookie = t.signIn(o.as);
+ let body = o.body;
+ if (o.json !== undefined) { body = Buffer.from(JSON.stringify(o.json)); headers['content-type'] = 'application/json'; }
+ if (o.form !== undefined) { body = Buffer.from(new URLSearchParams(o.form).toString()); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ if (body != null) headers['content-length'] = String(Buffer.byteLength(body));
+ const u = new URL(t.base + p);
+ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: o.method || (body != null ? 'POST' : 'GET'), headers }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => {
+ const buf = Buffer.concat(chunks);
+ const text = buf.toString('utf8');
+ resolve({ status: res.statusCode, headers: { get: (k) => res.headers[String(k).toLowerCase()] || null }, text, buffer: buf, json() { return JSON.parse(text); } });
+ });
+ });
+ req.on('error', reject);
+ if (body != null) req.write(body);
+ req.end();
+ });
+}
+
+
+(async () => {
+ const t = await boot(); const get = hostClient(t); const a = t.network.addUser('galleryauthor'); const b = t.network.addUser('reader');
+ const same = { 'sec-fetch-site': 'same-origin' };
+ try {
+ const image = (await t.ctx.pics.upload({requester: `user:${a.subject}`}, {buffer: await sharp({create:{width:120,height:90,channels:3,background:'#246'}}).png().toBuffer(), name:'forest.png'}, {title:'Forest'})).image;
+ const pub = (as, json) => get(`/api/v1/images/${image.slug}/publication`, { as, json, headers: same });
+ await check('resumable chunks enforce ownership, offsets and idempotent completion', async () => {
+ const bytes = await sharp({create:{width:31,height:41,channels:3,background:'#a42'}}).png().toBuffer();
+ const created = await get('/api/v1/uploads',{as:a,json:{size:bytes.length,content_type:'image/png'},headers:same});
+ assert.equal(created.status,201,created.text); const u=created.json();
+ assert.equal((await get(`/api/v1/uploads/${u.id}`,{as:b})).status,404);
+ const send = (offset, body) => get(`/api/v1/uploads/${u.id}`,{as:a,method:'PATCH',body,headers:{...same,'content-type':'application/octet-stream','upload-offset':String(offset)}});
+ assert.equal((await send(1,bytes)).status,409);
+ assert.equal((await send(0,bytes)).status,200);
+ const complete = () => get(`/api/v1/uploads/${u.id}/complete`,{as:a,method:'POST',headers:same});
+ const first = await complete(); assert.equal(first.status,201,first.text);
+ const again = await complete(); assert.equal(again.status,200,again.text); assert.equal(first.json().id,again.json().id);
+ });
+ await check('new uploads are unlisted: reachable by their link, never in the gallery', async () => {
+ assert.equal((await get(`/i/${image.slug}`)).status,200);
+ assert.equal((await get(`/api/v1/pictures/${image.id}`)).status,200);
+ assert.equal((await get('/api/v1/gallery')).json().pictures.length,0);
+ assert.equal((await get(`/i/${image.slug}`,{as:a})).status,200);
+ });
+ await check('publishing requires owner, same origin and valid tags', async () => {
+ // The picture is visible by its link, so a stranger is refused, not told it is missing.
+ assert.equal((await pub(b,{listed:true})).status,403);
+ assert.equal((await get(`/api/v1/images/${image.slug}/publication`,{as:a,json:{listed:true}})).status,403);
+ assert.equal((await pub(a,{listed:true,tags:['<script>']})).status,422);
+ assert.equal((await pub(a,{listed:true,tags:['forest']})).status,200);
+ });
+ await check('public gallery, search, HTML and indexing expose only published pictures', async () => {
+ const list = (await get('/api/v1/gallery?tag=forest')).json(); assert.equal(list.pictures.length,1); assert.equal(list.pictures[0].title,'Forest');
+ assert.equal((await get('/api/v1/gallery?q=unmatched')).json().pictures.length,0);
+ const home = await get('/'); assert.equal(home.status,200,home.text); assert.ok(home.text.includes('Forest'));
+ const page = await get(`/i/${image.slug}`); assert.ok(page.text.includes('ImageObject')); assert.ok(page.text.includes('index, follow'));
+ assert.ok((await get('/sitemap.xml')).text.includes(`/i/${image.slug}`));
+ });
+ await check('likes and daily views are idempotent and public-only', async () => {
+ for(let i=0;i<2;i++) assert.equal((await get(`/api/v1/images/${image.slug}/like`,{as:b,method:'PUT',headers:same})).status,200);
+ await get(`/i/${image.slug}`); await get(`/i/${image.slug}`);
+ const row = (await get('/api/v1/gallery')).json().pictures[0]; assert.equal(row.likes,1); assert.equal(row.views,1);
+ });
+ await check('narrowing sharing removes picture from discovery immediately', async () => {
+ await t.ctx.sharing.setAudience({kind:'user',subject:a.subject},'picture',image.id,'private');
+ assert.equal((await get('/api/v1/gallery')).json().pictures.length,0);
+ assert.equal((await get(`/api/v1/pictures/${image.id}`)).status,404);
+ assert.equal((await get(`/i/${image.slug}/thumb.webp`)).status,404);
+ });
+ await check('moderation removes public pictures and owner can unpublish', async () => {
+ assert.equal((await pub(a,{listed:true})).status,200);
+ await t.ctx.s.db.query("UPDATE pics_images SET state = 'hidden' WHERE id = $1",[image.id]);
+ assert.equal((await get('/api/v1/gallery')).json().pictures.length,0);
+ assert.equal((await pub(a,{listed:true})).status,422);
+ assert.equal((await pub(a,{listed:false})).status,200);
+ });
+ } finally { await t.close(); } done();
+})().catch(e=>{process.stderr.write(e.stack+'\n');process.exit(1);});
@@ -72,8 +72,8 @@ const PAGES = ['/exif', '/content-policy', '/ai-training'];
@@ -72,8 +72,8 @@ const PAGES = ['/exif', '/content-policy', '/ai-training'];
assert.ok(page.includes(reason), `the page says: ${reason}`);
assert.ok(page.includes(reason), `the page says: ${reason}`);
}
}
assert.ok(page.includes(`${t.config.pics.reportsToHide} reports hide a picture`), 'the number of reports is the configured one');
assert.ok(page.includes(`${t.config.pics.reportsToHide} reports hide a picture`), 'the number of reports is the configured one');
- assert.ok(page.includes('No feed to scroll'), 'it says there is no feed');
- assert.ok(page.includes('No counts to chase'), 'it says there are no counts');
+ assert.ok(page.includes('Public sharing is opt-in'), 'publication requires consent');
+ assert.ok(page.includes('Counts on public pictures only'), 'counts are limited to published pictures');
assert.ok(page.includes('/dmca'), 'it points at the DMCA page');
assert.ok(page.includes('/dmca'), 'it points at the DMCA page');
});
});
@@ -92,10 +92,10 @@ const PAGES = ['/exif', '/content-policy', '/ai-training'];
@@ -92,10 +92,10 @@ const PAGES = ['/exif', '/content-policy', '/ai-training'];
assert.ok(home.includes('The hidden data is removed by us'), 'the home says who removes the metadata');
assert.ok(home.includes('The hidden data is removed by us'), 'the home says who removes the metadata');
assert.ok(home.includes('href="/exif"'), 'and links the page that explains it');
assert.ok(home.includes('href="/exif"'), 'and links the page that explains it');
for (const path of PAGES) assert.ok(home.includes(`href="${path}"`), `the home links ${path}`);
for (const path of PAGES) assert.ok(home.includes(`href="${path}"`), `the home links ${path}`);
- assert.ok(home.includes('no counts to chase'), 'the home says why there is no feed');
+ assert.ok(home.includes('keep yours unlisted'), 'the home explains that an upload stays unlisted');
// The line sits under the upload, not above it: the page's main job comes first.
// The line sits under the upload, not above it: the page's main job comes first.
assert.ok(home.indexOf('data-pics-upload') < home.indexOf('The hidden data is removed by us'), 'the upload comes first');
assert.ok(home.indexOf('data-pics-upload') < home.indexOf('The hidden data is removed by us'), 'the upload comes first');
- assert.ok(home.indexOf('The hidden data is removed by us') < home.indexOf('>How it works</h2>'), 'and the line before the rest');
+ assert.ok(home.indexOf('The hidden data is removed by us') < home.indexOf('How it works</span></h2>'), 'and the line before the rest');
});
});
await check('the policies are in the sitemap and in llms.txt, and not blocked for crawlers', async () => {
await check('the policies are in the sitemap and in llms.txt, and not blocked for crawlers', async () => {
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
* Sharing on MediaHub pictures and albums (design 2026-11-06, VISIBILITY-EVERYWHERE step 4;
* Sharing on MediaHub pictures and albums (design 2026-11-06, VISIBILITY-EVERYWHERE step 4;
- * openvibe-sdk/share). Everyone by default (a picture has always been reachable by its link),
+ * openvibe-sdk/share). Unlisted by default (publishing is explicit),
* Only me and Specific people narrow it; the share row decides the page, the file, the thumbnail
* Only me and Specific people narrow it; the share row decides the page, the file, the thumbnail
* and an album, and the gate never shows the picture.
* and an album, and the gate never shows the picture.
*/
*/
@@ -49,10 +49,10 @@ function hostClient(t) {
@@ -49,10 +49,10 @@ function hostClient(t) {
const putShare = (kind, id, body) => share(kind, id, { method: 'PUT', as: kate, json: body });
const putShare = (kind, id, body) => share(kind, id, { method: 'PUT', as: kate, json: body });
const row = async (id) => await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['mediahub', 'picture', id]);
const row = async (id) => await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['mediahub', 'picture', id]);
- await check('a new picture is registered Everyone, owned by its uploader', async () => {
+ await check('a new picture is registered for anyone with the link, owned by its uploader', async () => {
const img = await upload();
const img = await upload();
const s = await row(img.id);
const s = await row(img.id);
- assert.strictEqual(s.audience, 'public');
+ assert.strictEqual(s.audience, 'link');
assert.deepStrictEqual(s.owner_subject, { type: 'user', id: kate.subject });
assert.deepStrictEqual(s.owner_subject, { type: 'user', id: kate.subject });
assert.strictEqual((await get(`/i/${img.slug}`)).status, 200);
assert.strictEqual((await get(`/i/${img.slug}`)).status, 200);
assert.strictEqual((await get(`/i/${img.slug}.png`)).status, 200);
assert.strictEqual((await get(`/i/${img.slug}.png`)).status, 200);
@@ -73,7 +73,7 @@ function multipart(fields, file) {
@@ -73,7 +73,7 @@ function multipart(fields, file) {
assert.ok(/<script type="application\/ld\+json">[^<]*"@type":"WebSite"/.test(home.text), 'the home says what the site is (JSON-LD)');
assert.ok(/<script type="application\/ld\+json">[^<]*"@type":"WebSite"/.test(home.text), 'the home says what the site is (JSON-LD)');
assert.strictEqual((await t.get('/files', { headers: PICS, as: kim })).status, 404, 'no drive pages on the pics host');
assert.strictEqual((await t.get('/files', { headers: PICS, as: kim })).status, 404, 'no drive pages on the pics host');
assert.ok((await t.get('/', { headers: { Host: 'openvibe.download' } })).text.indexOf('Share pictures.') === -1, 'the drive host is not Pics');
assert.ok((await t.get('/', { headers: { Host: 'openvibe.download' } })).text.indexOf('Share pictures.') === -1, 'the drive host is not Pics');
- assert.strictEqual((await t.get('/robots.txt', { headers: PICS })).text.includes('Disallow: /i/'), true, 'pictures are not crawled');
+ assert.strictEqual((await t.get('/robots.txt', { headers: PICS })).text.includes('Disallow: /i/'), false, 'public pictures can be crawled; private pages stay noindex');
});
});
await check('the home: how it works, the links, the API, the limits from config, questions that match its FAQPage', async () => {
await check('the home: how it works, the links, the API, the limits from config, questions that match its FAQPage', async () => {
@@ -81,13 +81,14 @@ function multipart(fields, file) {
@@ -81,13 +81,14 @@ function multipart(fields, file) {
assert.strictEqual((home.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
assert.strictEqual((home.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
assert.match(home, /<title>OpenVibe\.Pics: share and embed pictures with a link<\/title>/);
assert.match(home, /<title>OpenVibe\.Pics: share and embed pictures with a link<\/title>/);
const sections = ['How it works', 'The links a picture gets', 'Upload from your own app', 'What a free account gets', 'Questions', 'More on OpenVibe'];
const sections = ['How it works', 'The links a picture gets', 'Upload from your own app', 'What a free account gets', 'Questions', 'More on OpenVibe'];
- assert.deepStrictEqual(sections.filter((h) => !home.includes(`>${h}</h2>`)), [], 'every section is there');
+ // The showcase kit puts every section title in a span inside its heading.
+ assert.deepStrictEqual(sections.filter((h) => !home.includes(`${h}</span></h2>`)), [], 'every section is there');
assert.ok(/<td[^>]*>20 MB<\/td>/.test(home) && /<td[^>]*>200<\/td>/.test(home) && /<td[^>]*>5 GB<\/td>/.test(home), 'the limits are the configured ones');
assert.ok(/<td[^>]*>20 MB<\/td>/.test(home) && /<td[^>]*>200<\/td>/.test(home) && /<td[^>]*>5 GB<\/td>/.test(home), 'the limits are the configured ones');
- assert.ok(home.includes('/api/v1/images \\\n -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n'), 'the curl sample keeps its line breaks');
+ assert.ok(home.includes('/api/v1/images \\\n -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\n'), 'the curl sample keeps its line breaks');
const ld = [...home.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1])));
const ld = [...home.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1])));
const faq = ld.find((x) => x['@type'] === 'FAQPage');
const faq = ld.find((x) => x['@type'] === 'FAQPage');
// The questions section's own disclosures: the shared footer's "More from OpenVibe" is one too (openvibe-shared 4.15).
// The questions section's own disclosures: the shared footer's "More from OpenVibe" is one too (openvibe-shared 4.15).
- const questions = home.slice(home.indexOf('>Questions</h2>'), home.indexOf('>More on OpenVibe</h2>'));
+ const questions = home.slice(home.indexOf('Questions</span></h2>'), home.indexOf('More on OpenVibe</span></h2>'));
const shown = [...questions.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));
const shown = [...questions.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));
assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the FAQPage is on the page');
assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the FAQPage is on the page');
const map = (await t.get('/sitemap.xml', { headers: PICS })).text;
const map = (await t.get('/sitemap.xml', { headers: PICS })).text;
@@ -143,7 +144,7 @@ function multipart(fields, file) {
@@ -143,7 +144,7 @@ function multipart(fields, file) {
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.ok(page.text.includes('The coast'));
assert.ok(page.text.includes('The coast'));
assert.ok(page.text.includes(`https://openvibe.pics${direct}`), 'the direct link is on the page');
assert.ok(page.text.includes(`https://openvibe.pics${direct}`), 'the direct link is on the page');
- assert.ok(page.text.includes('No account needed'), 'the copy panel says no account is needed');
+ assert.ok(page.text.includes('follow the audience you choose'), 'the copy panel explains access control');
assert.ok(page.text.includes('[img]https://openvibe.pics'), 'the BBCode snippet is on the page');
assert.ok(page.text.includes('[img]https://openvibe.pics'), 'the BBCode snippet is on the page');
assert.ok(page.text.includes('data-ovk-copy'), 'the links have a copy button (the kit)');
assert.ok(page.text.includes('data-ovk-copy'), 'the links have a copy button (the kit)');
assert.ok(page.text.includes('noindex'));
assert.ok(page.text.includes('noindex'));