openvibers/openvibe.mediahub Commits

OpenVibe.Pics says in writing what it does with a picture: the EXIF page, the content policy, the AI stance, and a copy panel (#211)

OP OpenVibers committed 13 days ago · a8c7bad253 · 8 files, +446 −15

The market research (pics, 2026-10-30) asked for an exact EXIF answer, an AI
stance and a content policy, and for the links of a picture to be copyable
without an account. Three policy pages now say it, each built from the
service's own configuration and from what the code does:

  /exif            the metadata (GPS, camera, the time it was taken) is taken
                   out by us, the file you upload is not kept, and the page
                   says what we cannot do: the stripping happens after your
                   file reaches us
  /content-policy  what is not allowed (the report reasons the code offers),
                   how a rule is enforced (the configured number of reports),
                   and what this site does not do
  /ai-training     we do not train on the pictures you upload; the one thing
                   that asks a model is the alt text you ask for, and it keeps
                   a hash, not the picture. A promise, not a lock

The home says in one line who removes the metadata, and a picture's page shows
its links side by side — the file itself, Markdown, BBCode and HTML — with the
kit's copy button, without an account. The upload result shows the same set.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 42f843a..f495aba 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,15 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
++- OpenVibe.Pics says in writing what it does with a picture (2026-10-30, the market research): three policy pages —
++ `/exif` (the metadata — the GPS position, the camera, the time it was taken — is taken out by us, and the file you
++ upload is not kept), `/content-policy` (what is not allowed, how a rule is enforced, and what this site does not
++ do) and `/ai-training` (we do not train on the pictures you upload; the one thing that asks a model is the alt text
++ you ask for, and it keeps a hash, not the picture). Every number on them comes from the service's own
++ configuration, and where the code cannot promise something the page says so: the stripping happens after your file
++ arrives, and a promise about AI is not a lock. The home says in one line who removes the metadata, and a picture's
++ page shows its links side by side — the file itself, Markdown, BBCode and HTML — copyable without an account.
++
- The watch pages say what the video is (2026-10-30, the market research): under the title, the kind — a stream
recording, a clip or an upload — with the day it was streamed (Live's own stream time), its length and its views.
Every word comes from the item's own fields; a fact Live does not give is left out, not guessed. A clip credits the
ddiff --git a/public/css/pics.css b/public/css/pics.css
iindex d199256..8081356 100644
---- a/public/css/pics.css
++++ b/public/css/pics.css
@@@ -59,6 +59,9 @@
.ovp-progress { height: 6px; border-radius: 999px; background: var(--bg-tertiary, #16223a); overflow: hidden; }
.ovp-progress span { display: block; height: 100%; width: 0; background: var(--ovp-pink); transition: width .15s linear; }
.ovp-result .ovp-copy-row input { font-size: .82rem; }
++/* The links of a picture that has just been uploaded: the file itself and the snippets, side by side. */
++.ovp-result-links { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); gap: 8px 14px; margin-top: 4px; }
++.ovp-result-note { margin: 2px 0 0; color: var(--ovp-dim); font-size: .85rem; }
/* Sections, grids */
.ovp-section { margin-top: 2.2rem; }
@@@ -94,8 +97,17 @@
.ovp-desc { max-width: 70ch; white-space: pre-line; overflow-wrap: anywhere; }
.ovp-ai { max-width: 70ch; margin: 8px 0 0; color: var(--ovp-dim); overflow-wrap: anywhere; }
.ovp-ai-label { display: inline-block; margin-right: 6px; padding: 1px 8px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--ovp-pink) 45%, var(--ovp-line-strong)); color: var(--ovp-accent-text); font-size: .74rem; font-weight: 700; vertical-align: 1px; }
--.ovp-share { margin-top: 1.6rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); gap: 10px 16px; }
--.ovp-share h2 { grid-column: 1 / -1; margin-bottom: 0; }
++.ovp-share { margin-top: 1.6rem; }
++.ovp-share h2 { margin-bottom: 0; }
++.ovp-share-lede { margin: -.25rem 0 .9rem; max-width: 70ch; color: var(--ovp-dim); }
++/* The copy panel: the links of a picture side by side, each one a snippet to copy. */
++.ovp-link-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 12px; }
++.ovp-link-grid > * { min-width: 0; }
++.ovp-link-card { display: grid; gap: 6px; align-content: start; min-width: 0; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--ovp-line); background: var(--ovp-card); }
++.ovp-link-card h3 { margin: 0; font-size: .95rem; }
++.ovp-link-note { margin: 0; color: var(--ovp-dim); font-size: .85rem; }
++/* A long URL in a snippet wraps instead of pushing the page sideways (the site's own "pre code" rule does not wrap). */
++.ovp-link-card .ovk-snip > pre > code { font-size: inherit; white-space: pre-wrap; overflow-wrap: anywhere; }
.ovp-copy { display: grid; gap: 4px; min-width: 0; }
.ovp-copy label { font-size: .8rem; font-weight: 700; color: var(--ovp-dim); }
.ovp-copy-row { display: flex; gap: 6px; min-width: 0; }
@@@ -155,6 +167,7 @@
/* Home: how it works, the links, the API, the limits, the rest of the network */
.ovp-section-lede { margin: -.25rem 0 .9rem; max-width: 70ch; color: var(--ovp-dim); }
++.ovp-note { margin: 1rem 0 0; max-width: 70ch; color: var(--ovp-dim); font-size: .92rem; }
.ovp-steps { list-style: none; counter-reset: ovp-step; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); }
.ovp-steps li { counter-increment: ovp-step; padding: 16px 18px; border-radius: 14px; border: 1px solid var(--ovp-line); background: var(--ovp-card); }
.ovp-steps li::before { content: counter(ovp-step); display: inline-grid; place-items: center; width: 28px; height: 28px; margin-bottom: .5rem; border-radius: 50%; background: var(--ovp-pink); color: var(--ovp-on-pink); font-weight: 800; font-size: .9rem; }
@@@ -166,6 +179,7 @@
.ovp-links dd { margin: 0; min-width: 0; }
.ovp-links code, .ovp-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .85rem; overflow-wrap: anywhere; }
.ovp-code { margin: 0; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--ovp-line); background: var(--bg-secondary, #0f1729); overflow-x: auto; white-space: pre; overflow-wrap: normal; }
++.ovp-code code { font-size: inherit; white-space: pre-wrap; overflow-wrap: anywhere; }
.ovp-table-wrap { overflow-x: auto; max-width: 640px; }
.ovp-limits { min-width: 0; width: 100%; }
.ovp-limits th, .ovp-limits td { text-align: left; padding: 8px 12px; white-space: normal; }
@@@ -174,3 +188,20 @@
.ovp-more a:hover, .ovp-more a:focus-visible { border-color: var(--ovp-pink); }
.ovp-more span { font-size: .88rem; color: var(--ovp-dim); }
@media (max-width: 640px) { .ovp-links > div { grid-template-columns: minmax(0, 1fr); } }
++
++/* The policy pages (server/pics/policy.js): the OpenVibe Kit's own pieces, in one column that fits a phone.
++ The kit's head draws its own bleed, so it is kept inside the column here. */
++.ovp-policy { max-width: 860px; margin: 0 auto; }
++.ovp-policy .ovk-head { margin-left: 0; margin-right: 0; }
++.ovp-policy-body { padding: 0 14px 14px; }
++.ovp-policy-body > p { margin: 0 0 10px; color: var(--ovp-dim); }
++.ovp-policy-body > p:last-child { margin-bottom: 0; }
++.ovp-policy .ovk-fold-body > p { margin: 0 0 10px; color: var(--ovp-dim); }
++.ovp-policy .ovk-fold-body > p:last-child { margin-bottom: 0; }
++.ovp-policy .ovk-notice { margin: 12px 0; }
++.ovp-policy .ovk-row-sub { color: var(--ovp-dim); }
++/* The kit's part labels are .74rem (11.84px); a phone wants 12px or more. */
++.ovp-policy .ovk-section.ovk-section > h2 { font-size: .78rem; }
++/* The kit's rows are one line each; on a policy page the whole sentence has to show. */
++.ovp-policy .ovk-row-sub > span:first-child,
++.ovp-policy .ovk-row-top strong { white-space: normal; overflow: visible; text-overflow: clip; }
ddiff --git a/public/js/pics.js b/public/js/pics.js
iindex 9610361..518eec3 100644
---- a/public/js/pics.js
++++ b/public/js/pics.js
@@@ -57,7 +57,10 @@
var id = 'up-' + (++seq);
var field = el('input', { id: id, type: 'text', readonly: '', spellcheck: 'false', 'aria-label': label });
field.value = value;
-- return el('div', { className: 'ovp-copy-row' }, [field, el('button', { className: 'ovp-btn', type: 'button', 'data-copy': id, text: 'Copy' })]);
++ return el('div', { className: 'ovp-copy' }, [
++ el('label', { for: id, text: label }),
++ el('div', { className: 'ovp-copy-row' }, [field, el('button', { className: 'ovp-btn', type: 'button', 'data-copy': id, text: 'Copy' })]),
++ ]);
}
function row(file) {
@@@ -92,8 +95,15 @@
var name = r.main.querySelector('.ovp-result-name');
name.textContent = '';
name.appendChild(el('a', { href: body.links.page, text: body.title || 'Your picture' }));
-- r.main.appendChild(copyRow('Link to the picture’s page', body.links.page));
-- r.main.appendChild(copyRow('Direct link', body.links.direct));
++ // The links of the new picture, side by side: the file itself, and the snippets to embed it.
++ var links = body.links;
++ var box = el('div', { className: 'ovp-result-links' });
++ box.appendChild(copyRow('Direct link (the picture itself)', links.direct || ''));
++ if (links.markdown) box.appendChild(copyRow('Markdown', links.markdown));
++ if (links.bbcode) box.appendChild(copyRow('BBCode (forums)', links.bbcode));
++ if (links.html) box.appendChild(copyRow('HTML', links.html));
++ r.main.appendChild(box);
++ r.main.appendChild(el('p', { className: 'ovp-result-note', text: 'Anyone with the link can open it — no account needed.' }));
if (body.delete_url) r.main.appendChild(el('div', { className: 'ovp-result-error', text: 'Keep this delete link, shown only now: ' + body.delete_url }));
} else if (xhr.status === 401) {
fail(r, 'Sign in with OpenVibe to upload.');
ddiff --git a/server/pics/layout.js b/server/pics/layout.js
iindex 226e9e4..2754f41 100644
---- a/server/pics/layout.js
++++ b/server/pics/layout.js
@@@ -14,6 +14,7 @@ const path = require('path');
const appIcon = require('openvibe-shared/app-icon');
const shell = require('openvibe-shared/shell');
const cache = require('openvibe-shared/cache-policy');
++const ovServe = require('openvibe-shared/serve');
const { html, raw, esc, render } = require('../render/html');
const NETWORK_URL = 'https://openvibe.network';
@@@ -32,7 +33,8 @@ function assetVersion(rel) {
const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
/** o: title, description, body (html), baseUrl, path, index, noindex, viewer, private, crumbs [{label, href}], scripts [rel],
-- * jsonLd (structured data for the page), image (its share image, an https URL), ogType */
++ * styles [shared file], sharedScripts [shared file], jsonLd (structured data for the page), image (its share image, an
++ * https URL), ogType */
function renderPage(o) {
// The front page's title says what the site is (search results show it; at most 60 characters).
const title = o.title || (o.path === '/' ? `${SITE_NAME}: share pictures, fast and open` : SITE_NAME);
@@@ -47,7 +49,9 @@ function renderPage(o) {
${appIcon.headTags({ site: 'media-hub' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/pics.css')}">
--${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;
++${(o.styles || []).map((rel) => `<link rel="stylesheet" href="${ovServe.url(rel)}">`).join('\n')}
++${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}
++${(o.sharedScripts || []).map((rel) => `<script src="${ovServe.url(rel)}" defer></script>`).join('\n')}`;
const account = signedIn
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${encodeURIComponent(pathNow)}">Sign out</a>`
: html`<a href="/auth/login?next=${encodeURIComponent(pathNow)}">Sign in with OpenVibe</a>`;
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex 92116d9..92f6374 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -10,6 +10,7 @@
* /i/<slug>/thumb.webp its 640-pixel thumbnail
* /mine, /albums your pictures and albums; /a/<slug> an album
* /staff the report queue (Network admin and global_mod): restore, hide, take down
++ * /exif /content-policy /ai-training the policy pages (server/pics/policy.js): the metadata, the content rules, AI
* /api/v1/… the API (server/pics/api.js)
* /robots.txt /llms.txt /terms /privacy /dmca /updates
*
@@@ -37,6 +38,8 @@ const { send, assetVersion, SITE_NAME } = require('./layout');
const store = require('./store');
const { createPicsApi } = require('./api');
const { createPicVariants } = require('./variants');
++const kit = require('openvibe-shared/kit');
++const { PATHS: POLICY_PATHS, policyPage } = require('./policy');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const FILE_CACHE = 'public, max-age=600, s-maxage=600';
@@@ -208,7 +211,7 @@ function createPicsRoutes(ctx) {
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: 'What is removed from my pictures?', a: 'Everything hidden in the file: the GPS position, the camera\'s serial number and the rest of its metadata. Every picture is decoded and saved again before anyone sees it.' },
++ { 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.' },
@@@ -231,9 +234,10 @@ function createPicsRoutes(ctx) {
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, and nothing about you hidden in the file.</p></header>
++<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>`}
${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>
@@@ -268,6 +272,11 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
<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 do not train on the pictures you upload. One thing asks a model, and only when you ask for it.</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>
@@@ -310,6 +319,9 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
function copyRow(label, value, id) {
return html`<div class="ovp-copy"><label for="${id}">${label}</label><div class="ovp-copy-row"><input id="${id}" type="text" readonly value="${value}" spellcheck="false"><button class="ovp-btn" type="button" data-copy="${id}">Copy</button></div></div>`;
}
++ /** One link of a picture, in the copy panel: its name, what it is for, and the text to copy (the OpenVibe Kit's
++ * snippet, with a copy button in its corner; without JavaScript the text is there to select and copy by hand). */
++ const linkCard = (title, note, code, id) => html`<div class="ovp-link-card"><h3>${title}</h3><p class="ovp-link-note">${note}</p>${raw(kit.snippet({ code, id }))}</div>`;
async function picturePage(req, res, row, { deleteKey = null, fresh = false, message = null, tone = 'ok' } = {}, status = 200) {
const who = whoOf(req);
@@@ -323,7 +335,7 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
title, noindex: true,
description: row.description || `${title}: a picture on ${SITE_NAME}.`,
image: hidden ? undefined : L.direct, ogType: 'article',
-- scripts: ['js/pics.js'],
++ scripts: ['js/pics.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],
private: deleteKey ? true : undefined,
body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}
${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}
@@@ -336,7 +348,14 @@ ${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>
<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>
--${copyRow('Link to this page', L.page, 'c-page')}${copyRow('Direct link (for <img> and forums)', L.direct, 'c-direct')}${copyRow('Markdown', L.markdown, 'c-md')}${copyRow('BBCode (forums)', L.bbcode, 'c-bb')}${copyRow('HTML', L.html, 'c-html')}
++<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>
++<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}`)}
++${linkCard('BBCode', 'For a forum.', L.bbcode, `snip-bb-${row.slug}`)}
++${linkCard('HTML', 'For a page of your own.', L.html, `snip-html-${row.slug}`)}
++</div>
++<p class="muted">The <a href="${L.page}">page of this picture</a> shows it with its details, and carries the report form.</p>
</section>
${!hidden && variants.widthsFor(row).length ? html`<section class="ovp-section ovp-sizes" aria-labelledby="h-sizes"><h2 id="h-sizes">Resize and convert</h2>
<p class="muted">The same picture at another width or in another format, each a link of its own${row.animated ? ' (WebP keeps the animation; the others take its first frame)' : ''}.</p>
@@@ -572,19 +591,39 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
return res.redirect(303, `/staff?state=${row.state === 'active' ? 'reported' : 'hidden'}`);
});
++ // ── The policy pages ────────────────────────────────────
++ // What happens to a picture's hidden data, what may be put on the site, and what we do with pictures and AI.
++ // Each page is built from the service's own configuration (server/pics/policy.js), so every number on them is one
++ // the code enforces.
++ for (const kind of ['exif', 'content-policy', 'ai-training']) {
++ const spec = policyPage(kind, { cfg, reasons: pics.REASONS, reasonText: pics.REASON_TEXT });
++ r.get(spec.path, (req, res) => page(req, res, {
++ index: true, title: spec.title, description: spec.description,
++ styles: ['kit.css'], sharedScripts: ['kit-web.js'],
++ body: raw(`<div class="ovp-policy">${spec.body}</div>`),
++ }));
++ }
++
// ── 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'] })}`));
-- // The indexable pages: the home, the update log and the rules. 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].map((p) => ({ loc: `${base}${p}` })))));
++ .send(seo.sitemapXml(['/', '/updates', ...legal.PATHS, ...POLICY_PATHS].map((p) => ({ loc: `${base}${p}` })))));
// RFC 9116, canonical to this host (openvibe.pics).
r.get('/.well-known/security.txt', securityTxt.handler(base));
r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
name: SITE_NAME,
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).',
-- sections: [{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }, { title: 'Terms', url: `${base}/terms` }, { title: 'DMCA', url: `${base}/dmca` }] }],
++ sections: [
++ { title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }, { title: 'Terms', url: `${base}/terms` }, { title: 'DMCA', url: `${base}/dmca` }] },
++ { title: 'Policies', links: [
++ { title: 'What happens to your picture', url: `${base}/exif` },
++ { title: 'What you may put here', url: `${base}/content-policy` },
++ { title: 'Pictures and AI', url: `${base}/ai-training` },
++ ] },
++ ],
})));
r.get('/updates', (req, res) => page(req, res, {
index: true, title: 'What shipped on OpenVibe.Pics', description: 'Every change deployed to OpenVibe.Pics, newest first.',
ddiff --git a/server/pics/policy.js b/server/pics/policy.js
nnew file mode 100644
iindex 0000000..e8c0a41
---- /dev/null
++++ b/server/pics/policy.js
@@@ -0,0 +1,201 @@
++'use strict';
++
++/**
++ * OpenVibe.Pics' three policy pages, the ones the site's own promises rest on:
++ *
++ * /exif what happens to a picture's hidden data (server/pics/images.js does the work)
++ * /content-policy what may be put on the site, and what happens when it is (server/pics/service.js, the staff queue)
++ * /ai-training what we do with pictures and AI (server/pics/describe.js is the only path to a model)
++ *
++ * Rules for the words here:
++ * - Every number comes from the service's own configuration (config.pics) or from the rules the code
++ * enforces (the report reasons, the blocklist). Nothing is typed in by hand.
++ * - Every claim is one the code can back. Where the code cannot promise something (the stripping happens
++ * after the file arrives; a promise about AI is not a lock), the page says so.
++ * - The pages are plain HTML from the OpenVibe Kit (openvibe-shared/kit): they work without JavaScript,
++ * they fit a phone, and they wear the site's theme.
++ *
++ * policyPage(kind, { cfg, reasons, reasonText }) → { path, title, description, body }
++ */
++const kit = require('openvibe-shared/kit');
++const { formatBytes } = require('../drive/rules');
++
++const PATHS = ['/exif', '/content-policy', '/ai-training'];
++
++const size = (b) => formatBytes(b).replace(/\.0 /, ' ');
++const mega = (px) => `${Math.round(px / 1e6)} megapixels`;
++
++/** A paragraph of the page. */
++const p = (text) => `<p>${kit.esc(text)}</p>`;
++/** A paragraph with a link or two in it (the words are ours; the markup is written by hand). */
++const pm = (markup) => `<p>${markup}</p>`;
++/** A part of the page: its name, and the body under it. */
++const sec = (title, body, note = '') => kit.section({ title, note, body: `<div class="ovp-policy-body">${body}</div>` });
++/** The rows of a part, as one list without a frame of its own (the part is the frame). */
++const rows = (items) => kit.list(items, { flush: true });
++
++/** The steps of the EXIF page: what happens to a file, in order. */
++function exifSteps(cfg) {
++ return [
++ ['Your browser sends the file', 'It goes to OpenVibe.Pics over HTTPS, whole, with everything inside it. Nobody can strip what they never receive, so the file with its GPS reaches us first.'],
++ ['We check what it really is', `It has to decode as a JPEG, PNG, GIF, WebP or AVIF picture, within ${size(cfg.maxImageBytes)} and ${mega(cfg.maxPixels)}. A renamed script, an SVG or a half-written file is refused, and nothing is stored.`],
++ ['We make a new picture from it', 'The picture is decoded and saved again in the same format. The EXIF orientation is applied, so a photo taken sideways is shown upright, and the colour profile becomes sRGB. Every metadata block — EXIF with its GPS and camera, XMP, IPTC, comments — is dropped on the way out. The file we keep has none of them.'],
++ ['We keep the new picture, not your file', 'The new picture is stored as a private object in OpenVibe.Media, with a 640-pixel thumbnail beside it. Your uploaded file is never stored. One thing of it is kept: a SHA-256 hash, so that a picture which was taken down cannot be uploaded again — even resized or re-compressed.'],
++ ['Every other size comes from the new picture', 'The other widths and formats are made from the picture we keep, so they carry no metadata either.'],
++ ];
++}
++
++/** What we keep about a picture, once the metadata is gone. */
++function exifKept() {
++ return [
++ ['The picture itself', 'The bytes we made from your file, and a 640-pixel thumbnail.'],
++ ['What you wrote', 'The title, the description and the alt text, and who wrote them.'],
++ ['What the file is', 'Its size, its format, its width and height, and whether it moves.'],
++ ['Its hashes', 'The hash of the picture we keep, the hash of your file, and a difference hash used to spot near-copies.'],
++ ['When it arrived', 'The time it was uploaded, and when it was last looked at.'],
++ ['Who put it here', 'Your OpenVibe account, for a signed-in upload. For a signed-out upload, a keyed hash of the address, used only to count the uploads a day.'],
++ ];
++}
++
++/** The content policy: what is not allowed, and what the site does about it. The titles are the report reasons the
++ * code itself offers (server/pics/service.js), so the page and the report form always say the same thing. */
++function contentRules(reasons, reasonText) {
++ const why = {
++ child_safety: 'It is hidden the moment it is reported, taken down, and reported to NCMEC\'s CyberTipline.',
++ illegal: 'Where you are, or in the United States, where our servers are.',
++ copyright: 'A copyright owner sends a notice through the DMCA page, and we take the picture down.',
++ sexual: 'Including anyone else\'s private pictures.',
++ violence: 'A real threat of harm, or a picture of violence with no other purpose.',
++ harassment: 'Targeting a person with abuse, or content that attacks people for who they are.',
++ private_info: 'Addresses, phone numbers, documents, number plates: things people did not choose to publish.',
++ spam: 'The same picture over and over, adverts dressed as pictures, links to a fraud.',
++ other: 'Anything else that breaks the terms: report it, and a person looks at it.',
++ };
++ return reasons.map((r) => [reasonText[r] || r, why[r] || '']);
++}
++
++/**
++ * The three pages.
++ *
++ * @param kind 'exif' | 'content-policy' | 'ai-training'
++ * @param ctx { cfg (config.pics), reasons, reasonText } — the service's own numbers and rules
++ */
++function policyPage(kind, { cfg, reasons = [], reasonText = {} } = {}) {
++ if (kind === 'exif') {
++ const body = [
++ kit.head({
++ title: 'What happens to your picture',
++ sub: 'The hidden data: the GPS position, the camera, the time it was taken',
++ back: { href: '/', label: 'Home' },
++ }),
++ sec('The short answer', pm('We take it out. Your picture arrives here with its metadata — the GPS position, the camera, the lens, the time it was taken — and we remove all of it before anyone sees the picture. '
++ + 'The file you uploaded is not kept: only the picture we make from it, and a hash of your file, so that a picture taken down cannot come back.')),
++ sec('Step by step', rows(exifSteps(cfg).map(([title, sub]) => kit.row({ title, sub })))),
++ sec('What we keep once the metadata is gone', rows(exifKept().map(([title, sub]) => kit.row({ title, sub })))),
++ kit.fold({
++ title: 'What we cannot do',
++ body: [
++ p('We cannot show you the GPS of a picture you uploaded: we never keep it, so there is nothing left to show. Nor can we put the camera back.'),
++ p('And the stripping happens after your file reaches us — it is not a shield for the moment of upload. If the location matters to you, take it out on your own device before you upload, or keep the picture where the metadata stays on purpose.'),
++ ].join(''),
++ }),
++ kit.notice({ kind: 'info', text: 'This page describes what the code does. A test uploads a JPEG carrying a camera name and a GPS position, and checks that the picture served back has neither.' }),
++ sec('The other policies', rows([
++ kit.row({ href: '/content-policy', title: 'What you may put here', sub: 'The content policy, and what happens when a rule is broken' }),
++ kit.row({ href: '/ai-training', title: 'Pictures and AI', sub: 'We do not train on your uploads; here is the one thing that asks a model' }),
++ kit.row({ href: '/terms', title: 'Terms of service', sub: 'The rules of the network, in plain language' }),
++ kit.row({ href: '/privacy', title: 'Privacy', sub: 'What this service collects, and why' }),
++ ])),
++ ].join('');
++ return {
++ path: '/exif',
++ title: 'What happens to a picture: the GPS position, the camera, the rest',
++ description: 'OpenVibe.Pics removes the metadata from every picture before anyone sees it: the GPS position, the camera, the time it was taken. The file you upload is not kept.',
++ body,
++ };
++ }
++
++ if (kind === 'content-policy') {
++ const body = [
++ kit.head({
++ title: 'What you may put here',
++ 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('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.' }),
++ kit.row({ title: 'A child-safety report hides the picture at once', sub: 'It stops being visible to everyone but its owner and staff, while people look at it.' }),
++ kit.row({ title: `${cfg.reportsToHide} reports hide a picture`, sub: 'Reports from different people count together. A hidden picture is not found by anybody but its owner and staff.' }),
++ kit.row({ title: 'Staff decide what happens next', sub: 'They can show it again, keep it hidden, or take it down. A taken-down picture has its bytes deleted and its hashes put on a blocklist, so the same picture — or a resized, re-compressed copy of it — cannot be uploaded again.' }),
++ 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: '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.' }),
++ ])),
++ sec('Copyright and the law', pm('A copyright owner can send a notice through the <a href="/dmca">DMCA page</a>, and we take the picture down. The rules of the network itself are in the <a href="/terms">terms of service</a>; what this service collects is in the <a href="/privacy">privacy policy</a>.')),
++ sec('The other policies', rows([
++ kit.row({ href: '/exif', title: 'What happens to your picture', sub: 'The metadata: the GPS position, the camera, the time it was taken' }),
++ kit.row({ href: '/ai-training', title: 'Pictures and AI', sub: 'We do not train on your uploads; here is the one thing that asks a model' }),
++ ])),
++ ].join('');
++ return {
++ path: '/content-policy',
++ title: 'What you may put on OpenVibe.Pics',
++ description: 'The content policy for OpenVibe.Pics: what is not allowed, how a rule is enforced, and what this site does not do.',
++ body,
++ };
++ }
++
++ if (kind === 'ai-training') {
++ const body = [
++ kit.head({
++ title: 'Pictures and AI',
++ sub: 'We do not train on the pictures you upload',
++ back: { href: '/', label: 'Home' },
++ }),
++ sec('The short answer', pm('We do not train AI models on the pictures people upload here. Nobody at OpenVibe trains on them, and nothing about them is sold. '
++ + 'There is exactly one thing on this site that asks a model about a picture, and it only happens when you ask for it.')),
++ sec('The one path to a model', rows([
++ kit.row({ title: 'You press “Write alt text with AI”', sub: 'On a picture you own, that sends the picture’s 640-pixel thumbnail to OpenVibe.AI, which may hand it to an outside model provider, and writes back alt text, a caption and a few tags. The draft is marked as AI’s until you change it.' }),
++ kit.row({ title: 'The run keeps a hash, not the picture', sub: 'OpenVibe.AI’s run for a picture is not shared between callers, and keeps only the picture’s hash, not the picture (cache_mode none).' }),
++ kit.row({ title: `At most ${cfg.aiPerDay} drafts a day`, sub: 'OpenVibe.Pics asks OpenVibe.AI for no more than this many drafts a day for the whole site.' }),
++ kit.row({ title: 'Nothing else calls a model', sub: 'Storing, serving, resizing, converting and deleting a picture are all done by this service itself.' }),
++ ])),
++ kit.fold({
++ title: 'A promise, not a lock',
++ body: [
++ p('This is a promise about what we do, not a lock on what anybody else can do. Anyone who can see your picture can copy it, and a copy can end up in a training set somewhere else — no host can prevent that, and pretending otherwise would be dishonest.'),
++ p('What we can offer is the plain part: we keep no metadata, so a copy of your picture carries no camera, no GPS and no time. The picture itself is reachable only by the link you share, and you can delete it at any time.'),
++ pm('A host that keeps EXIF on purpose, and a host that offers a “do not train on this” tag, are different promises. We do not offer that tag yet; this page is what we can stand behind today. See also the <a href="/exif">metadata page</a>.'),
++ ].join(''),
++ }),
++ sec('What we do with a picture otherwise', rows([
++ kit.row({ title: 'We store it and serve it', sub: 'As a private object in OpenVibe.Media, and to the people who have the link.' }),
++ kit.row({ title: 'We show it where it is meant to go', sub: 'A picture embedded in a forum post, a chat message or a page of your own loads from this host, as the file itself.' }),
++ kit.row({ title: 'We delete it when you delete it', sub: 'Your pictures have a delete button, and an album can be deleted in one action.' }),
++ kit.row({ title: 'We do not profile you', sub: 'No adverts, no advertising trackers, and no data about your pictures is sold.' }),
++ ])),
++ sec('The other policies', rows([
++ kit.row({ href: '/exif', title: 'What happens to your picture', sub: 'The metadata: the GPS position, the camera, the time it was taken' }),
++ kit.row({ href: '/content-policy', title: 'What you may put here', sub: 'The content policy, and what happens when a rule is broken' }),
++ kit.row({ href: '/privacy', title: 'Privacy', sub: 'What this service collects, and why' }),
++ ])),
++ ].join('');
++ return {
++ path: '/ai-training',
++ title: 'Pictures and AI on OpenVibe.Pics',
++ description: 'OpenVibe.Pics does not train AI models on the pictures you upload. The one thing that asks a model about a picture is the alt text you can ask for, and it keeps a hash, not the picture.',
++ body,
++ };
++ }
++
++ return null;
++}
++
++module.exports = { PATHS, policyPage };
ddiff --git a/test/pics-policy.test.js b/test/pics-policy.test.js
nnew file mode 100644
iindex 0000000..26ba698
---- /dev/null
++++ b/test/pics-policy.test.js
@@@ -0,0 +1,134 @@
++'use strict';
++
++/**
++ * OpenVibe.Pics' policy pages (server/pics/policy.js): what happens to a picture's hidden data (/exif), what may be
++ * put on the site (/content-policy), and what we do with pictures and AI (/ai-training). They are built from the
++ * service's own configuration and from the rules the code enforces, so a number on a page is the configured one —
++ * this file boots the service with other limits and checks that the page follows them.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++/** The same client as pics.test.js: a request with a Host header (Node's fetch drops one). */
++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();
++ });
++}
++
++const PICS = { Host: 'openvibe.pics' };
++const PAGES = ['/exif', '/content-policy', '/ai-training'];
++
++(async () => {
++ const t0 = await boot();
++ const t = { ...t0, get: hostClient(t0) };
++ try {
++ await check('the three policy pages answer, are for search engines, and load the kit', async () => {
++ for (const path of PAGES) {
++ const page = await t.get(path, { headers: PICS });
++ assert.strictEqual(page.status, 200, path);
++ assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), `${path} is indexable`);
++ assert.ok(page.text.includes('/shared/kit.css'), `${path} wears the kit`);
++ assert.ok(page.text.includes('/shared/kit-web.js'), `${path} has the kit's behaviour`);
++ assert.ok(page.text.includes('class="ovk-head'), `${path} starts with the kit's head`);
++ assert.ok(page.text.includes('class="ovk-list'), `${path} has the kit's lists`);
++ assert.strictEqual((page.text.match(/<h1[\s>]/g) || []).length, 1, `${path} has one heading`);
++ }
++ });
++
++ await check('the EXIF page says what happens to the metadata, and what we cannot do', async () => {
++ const page = (await t.get('/exif', { headers: PICS })).text;
++ assert.ok(page.includes('the GPS position'), 'it names the GPS position');
++ assert.ok(page.includes('Your uploaded file is never stored'), 'the file itself is not kept');
++ assert.ok(page.includes('The file we keep has none of them'), 'the metadata is dropped');
++ assert.ok(page.includes('not a shield for the moment of upload'), 'it says the stripping happens after the upload');
++ assert.ok(page.includes('What we cannot do'), 'the fold says what it cannot do');
++ assert.ok(page.includes('OpenVibe.Media'), 'it names where the bytes live');
++ assert.ok(page.includes('/content-policy') && page.includes('/ai-training'), 'it links the other policies');
++ });
++
++ await check('the content policy lists the report reasons the code offers, and the configured number of reports', async () => {
++ const page = (await t.get('/content-policy', { headers: PICS })).text;
++ for (const reason of ['Child sexual abuse or exploitation', 'Other illegal content', 'Copyright infringement', 'Sexual content shared without consent', 'Graphic violence or threats', 'Harassment or hate', 'private information', 'Spam or a scam']) {
++ 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('/dmca'), 'it points at the DMCA page');
++ });
++
++ await check('the AI page says we do not train, and that a promise is not a lock', async () => {
++ const page = (await t.get('/ai-training', { headers: PICS })).text;
++ assert.ok(page.includes('We do not train AI models on the pictures people upload here'), 'the stance is stated');
++ assert.ok(page.includes('A promise, not a lock'), 'it says a promise is not a lock');
++ assert.ok(page.includes('Write alt text with AI'), 'it names the one path to a model');
++ assert.ok(page.includes(`At most ${t.config.pics.aiPerDay} drafts a day`), 'the cap is the configured one');
++ assert.ok(page.includes('cache_mode none'), 'it says the run keeps a hash, not the picture');
++ });
++
++ await check('the home says in one line what happens to the metadata, and links the three policies', async () => {
++ const home = (await t.get('/', { headers: PICS })).text;
++ 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');
++ // 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');
++ });
++
++ await check('the policies are in the sitemap and in llms.txt, and not blocked for crawlers', async () => {
++ const map = (await t.get('/sitemap.xml', { headers: PICS })).text;
++ for (const path of PAGES) assert.ok(map.includes(`<loc>https://openvibe.pics${path}</loc>`), `the sitemap lists ${path}`);
++ const llms = (await t.get('/llms.txt', { headers: PICS })).text;
++ for (const path of PAGES) assert.ok(llms.includes(`https://openvibe.pics${path}`), `llms.txt names ${path}`);
++ const robots = (await t.get('/robots.txt', { headers: PICS })).text;
++ for (const path of PAGES) assert.ok(!robots.includes(`Disallow: ${path}`), `robots.txt does not block ${path}`);
++ });
++
++ await check('a signed-in person sees the same policy pages', async () => {
++ const kim = t.network.addUser('kim');
++ for (const path of PAGES) {
++ const page = await t.get(path, { headers: PICS, as: kim });
++ assert.strictEqual(page.status, 200, path);
++ assert.ok(page.text.includes('class="ovk-head'), `${path} is the same page`);
++ }
++ });
++ } finally { await t0.close(); }
++
++ // ── The numbers on the pages are the configured ones ──
++ const t10 = await boot({ env: { MEDIAHUB_PICS_MAX_IMAGE_BYTES: '3145728', MEDIAHUB_PICS_REPORTS_TO_HIDE: '7', MEDIAHUB_PICS_AI_PER_DAY: '42' } });
++ const t2 = { ...t10, get: hostClient(t10) };
++ try {
++ await check('a limit on a policy page is the configured one, not a number typed in', async () => {
++ const exif = (await t2.get('/exif', { headers: PICS })).text;
++ assert.ok(exif.includes('3 MB'), 'the largest picture is the configured one');
++ assert.ok(!exif.includes('20 MB'), 'and not the default');
++ const content = (await t2.get('/content-policy', { headers: PICS })).text;
++ assert.ok(content.includes('7 reports hide a picture'), 'the reports that hide a picture are the configured ones');
++ const ai = (await t2.get('/ai-training', { headers: PICS })).text;
++ assert.ok(ai.includes('At most 42 drafts a day'), 'the daily drafts are the configured ones');
++ });
++ } finally { await t10.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/pics.test.js b/test/pics.test.js
iindex c50fdd6..49afc14 100644
---- a/test/pics.test.js
++++ b/test/pics.test.js
@@@ -142,7 +142,10 @@ function multipart(fields, file) {
const page = await t.get(`/i/${slug}`, { headers: PICS });
assert.strictEqual(page.status, 200);
assert.ok(page.text.includes('The coast'));
-- assert.ok(page.text.includes(`value="https://openvibe.pics${direct}"`));
++ 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('[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'));
assert.ok(page.text.includes(`content="https://openvibe.pics${direct}"`), 'og:image is the picture');
assert.ok(page.text.includes('Report this picture'));