openvibers/openvibe.mediahub Commits

Pics: a public gallery of the pictures people chose to publish (#347)

OP OpenVibers committed 4 days ago · 04615b5ce3 · 18 files, +565 −82

* Pics: a public gallery of the pictures people chose to publish

The home opens on a gallery of published pictures (Popular, New and Rising; search and
tags; the first thirty server-rendered) and the upload is a compact bar at the top. A
picture is unlisted by default, as it always was: anyone with the link can open it and
nobody else. Publishing is the owner's explicit choice, and only a published picture is
listed, counted, indexed or discussable.

- server/pics/gallery.js: the gallery (browse, search, tags, counts, publish) and the
  public predicate: listed, active, Everyone, with a share row that agrees.
- The picture page gains likes, the Community discussion, "Edit with AI", "Make a meme"
  and "Take a photo"; a published picture is indexable, with ImageObject JSON-LD, and
  joins the sitemap.
- API: GET /api/v1/gallery, GET /api/v1/pictures/:id, POST /api/v1/images/:slug/publication,
  PUT and DELETE /api/v1/images/:slug/like, GET /api/v1/images/:slug/bytes, and resumable
  uploads under /api/v1/uploads.
- The home's sections use the showcase kit (hero, steps, features, code, limits, FAQ and
  "More on OpenVibe" with NETWORK_JS), so every section title carries its icon.
- migrations/0027_pics_gallery.sql is expand-only: no picture is opted in by it.

* Merge remote-tracking branch 'origin/main' into pics-public-1109

* Pics: account deletion covers resumable uploads, and the migration follows main

pics_uploads names a person, so account export/deletion must cover or exempt it; like
mh_uploads it is an unfinished upload, erased with the account and left out of the export.
Rebased onto current main, so the expand-only migration moves to 0028 (main has
0027_video_likes.sql).

Checked: ov test account-data and the Pics suites; node --check server/account.js;
gitleaks.

* Merge remote-tracking branch 'origin/main' into pics-public-1109

* Gallery design: pictures first, one quiet caption, a single search field and a segmented order

Each card is the whole picture at its own shape with a 14 px radius, then the title (one line), who and the counts on one line (eye and heart glyphs, 1.2K style, exact numbers for screen readers), and tags as small chips. Search is one rounded field with its glyph and a pink Search button inside; Popular / New / Rising is a segmented pill. Pics' own tokens (--ovp-pink, --ovp-line, --ovp-card); the picture link is out of the tab order so the title is the card's one stop.

Changes

ddiff --git a/README.md b/README.md
iindex a33af1a..53ad34a 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/migrations/0028_pics_gallery.sql b/migrations/0028_pics_gallery.sql
nnew file mode 100644
iindex 0000000..a9a77d8
---- /dev/null
++++ b/migrations/0028_pics_gallery.sql
@@@ -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)
++);
ddiff --git a/public/css/pics.css b/public/css/pics.css
iindex 2d9e0df..9862ad0 100644
---- a/public/css/pics.css
++++ b/public/css/pics.css
@@@ -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; }
ddiff --git a/public/js/pics-gallery.js b/public/js/pics-gallery.js
nnew file mode 100644
iindex 0000000..27335e3
---- /dev/null
++++ b/public/js/pics-gallery.js
@@@ -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; }
++ });
++})();
ddiff --git a/server/account.js b/server/account.js
iindex 8cf238b..4ff65ae 100644
---- a/server/account.js
++++ b/server/account.js
@@@ -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' },
ddiff --git a/server/app.js b/server/app.js
iindex 39fb789..3c5a4df 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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'"],
ddiff --git a/server/pics/api.js b/server/pics/api.js
iindex 03f4501..20644b2 100644
---- a/server/pics/api.js
++++ b/server/pics/api.js
@@@ -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)) }));
});
ddiff --git a/server/pics/gallery.js b/server/pics/gallery.js
nnew file mode 100644
iindex 0000000..428a7b9
---- /dev/null
++++ b/server/pics/gallery.js
@@@ -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 };
ddiff --git a/server/pics/layout.js b/server/pics/layout.js
iindex fcd4986..9bae161 100644
---- a/server/pics/layout.js
++++ b/server/pics/layout.js
@@@ -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
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex 6ffeebf..4d03bbd 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -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>&lt;img&gt;</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/&lt;name&gt;</code></dd></div>
--<div><dt>The picture itself, in the format you uploaded</dt><dd><code>${base}/i/&lt;name&gt;.jpg</code></dd></div>
--<div><dt>Another width or format</dt><dd><code>${base}/i/&lt;name&gt;/w/640.avif</code></dd></div>
--<div><dt>Markdown, with your alt text</dt><dd><code>![alt text](${base}/i/&lt;name&gt;.jpg)</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: `![alt text](${base}/i/<name>.jpg)` },
++ ],
++}))}
++${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" name
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) => {
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="res
markdown: true,
base,
summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, any size or format.',
-- details: '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` },
],
});
ddiff --git a/server/pics/policy.js b/server/pics/policy.js
iindex 59a0589..01cc496 100644
---- a/server/pics/policy.js
++++ b/server/pics/policy.js
@@@ -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.' }),
])),
ddiff --git a/server/pics/resumable.js b/server/pics/resumable.js
nnew file mode 100644
iindex 0000000..268a2f7
---- /dev/null
++++ b/server/pics/resumable.js
@@@ -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 };
ddiff --git a/server/pics/service.js b/server/pics/service.js
iindex 2207241..d7fd2f9 100644
---- a/server/pics/service.js
++++ b/server/pics/service.js
@@@ -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) } : {}),
});
ddiff --git a/server/sharing.js b/server/sharing.js
iindex 9c60676..f619c71 100644
---- a/server/sharing.js
++++ b/server/sharing.js
@@@ -95,7 +95,9 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
if (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]);
}
ddiff --git a/test/pics-gallery.test.js b/test/pics-gallery.test.js
nnew file mode 100644
iindex 0000000..b0dec3d
---- /dev/null
++++ b/test/pics-gallery.test.js
@@@ -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);});
ddiff --git a/test/pics-policy.test.js b/test/pics-policy.test.js
iindex 9346600..7cdcb7e 100644
---- a/test/pics-policy.test.js
++++ b/test/pics-policy.test.js
@@@ -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 () => {
ddiff --git a/test/pics-sharing.test.js b/test/pics-sharing.test.js
iindex 8778d36..ad8fcf8 100644
---- a/test/pics-sharing.test.js
++++ b/test/pics-sharing.test.js
@@@ -1,7 +1,7 @@
'use strict';
/**
* Sharing on MediaHub pictures and albums (design 2026-11-06, VISIBILITY-EVERYWHERE step 4;
-- * openvibe-sdk/share). 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);
ddiff --git a/test/pics.test.js b/test/pics.test.js
iindex 1cc0bd6..7fb41ac 100644
---- a/test/pics.test.js
++++ b/test/pics.test.js
@@@ -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 &quot;Authorization: Bearer $OPENVIBE_TOKEN&quot; \\\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(/&#39;/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'));