openvibers/openvibe.mediahub Commits
openvibe.pics and openvibe.download homes say what you get and how: how it works, the links and API, the backup command, the real limits, questions, and where to go next on OpenVibe (#106)
Changes
@@@ -63,3 +63,9 @@ button.sc-btn.sc-primary { border: 0; }.field { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; max-width: 520px; }.field input[type=text], .field input[type=search], .field select, .field textarea, form.inline input[type=text] { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: inherit; }.field-row { display: flex; flex-wrap: wrap; gap: 12px; }++/* Questions on a home page: each a disclosure, answer under its question */++.faq { display: grid; gap: 8px; max-width: 860px; }++.faq details { border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, var(--bg-secondary, #101828)); }++.faq summary { cursor: pointer; padding: 12px 16px; font-weight: 700; }++.faq details[open] summary { border-bottom: 1px solid var(--border, #1f2d47); }++.faq p { margin: 0; padding: 12px 16px 14px; color: var(--text-secondary, #96a7c2); line-height: 1.6; }@@@ -146,3 +146,25 @@.ovp-size-table thead th { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-faint, #7386a3); font-weight: 700; }.ovp-size-table tbody th { font-weight: 700; }.ovp-size-table a { display: inline-block; min-width: 44px; padding: 4px 0; }++++/* 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-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; }++.ovp-steps h3 { margin: 0 0 6px; font-size: 1rem; }++.ovp-steps p { margin: 0; color: var(--ovp-dim); font-size: .92rem; }++.ovp-links { display: grid; gap: 8px; margin: 0; }++.ovp-links > div { display: grid; grid-template-columns: minmax(0, 15rem) minmax(0, 1fr); gap: 4px 14px; align-items: baseline; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--ovp-line); background: var(--ovp-card); }++.ovp-links dt { font-weight: 700; font-size: .9rem; }++.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-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; }++.ovp-more { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }++.ovp-more a { display: grid; gap: 2px; height: 100%; box-sizing: border-box; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--ovp-line); background: var(--ovp-card); color: inherit !important; text-decoration: none; }++.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); } }@@@ -14,12 +14,12 @@ const cache = require('openvibe-shared/cache-policy');const { asyncRouter } = require('./router');const SITE_NAME = 'OpenVibe.Download';--const DESCRIPTION = 'OpenVibe.Download — Your files, shared on your terms.';++const DESCRIPTION = 'A private drive on your OpenVibe account: upload files, share them with links you can take back, and back up a folder from your computer.';// /s/ (with the slash) rather than /s, or the rule would also swallow /safety, which is a public page.const DISALLOW = ['/auth/', '/api/', '/files', '/s/', '/staff'];const PAGE_TEXT = {-- '/': ['OpenVibe.Download home', 'OpenVibe.Download: Your files, shared on your terms. A private drive for a signed-in OpenVibe account, with share links that expire, can be revoked and need the downloader to be signed in too.'],++ '/': ['OpenVibe.Download home', 'OpenVibe.Download: Your files, shared on your terms. A private drive for a signed-in OpenVibe account, with share links that expire and can be revoked (for named people, anyone signed in, or a public download page), folder backups with the openvibe-download command, and an API.'],'/safety': ['Safety rules', 'What OpenVibe.Download allows and does not, who can upload and download, how reports work, and who to write to about abuse or copyright.'],'/updates': ['What shipped on OpenVibe.Download', 'This site\'s update log, from the network changelog feed.'],};@@@ -32,12 +32,21 @@ function siteUpdated() {try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }}++/** The home's meta description, with the free storage this deployment really gives (config.quotas). At most 160 characters. */++function homeDescription(config) {++ const gb = (b) => `${Math.round(Number(b) / (1024 ** 3))} GB`;++ const q = config.quotas || {};++ if (!q.bytes || !q.maxFileBytes) return DESCRIPTION;++ return `A private drive with ${gb(q.bytes)} free: upload files up to ${gb(q.maxFileBytes)}, share them with links you can take back, and back up a folder from your computer.`.slice(0, 160);++}++function homeJsonLd(config) {const site = String(config.baseUrl).replace(/\/+$/, '');++ const description = homeDescription(config);return [-- seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),-- seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe' }),-- seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),++ seo.jsonLd.website({ name: SITE_NAME, url: site, description }),++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description, category: 'BusinessApplication', keywords: 'file storage, file sharing, backup, cloud drive, openvibe' }),++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description, siteUrl: site }),];}@@@ -67,8 +76,8 @@ function createDiscoveryRoutes(ctx) {r.get('/llms.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({name: SITE_NAME,-- summary: 'OpenVibe.Download: Your files, shared on your terms.',-- details: 'Every page is server-rendered and readable without JavaScript.',++ summary: 'OpenVibe.Download: a private drive on your OpenVibe account, with share links you control and folder backups.',++ details: 'Upload files to your own drive, share a file or folder with named OpenVibe accounts, anyone signed in, or a public download page (links expire and can be revoked), and keep a folder on your computer backed up with the openvibe-download command (a sync key from /sync). Every page is server-rendered and readable without JavaScript.',sections: [{ title: 'Start here', links: [{ title: 'OpenVibe.Download', url: abs('/'), note: 'Your files, shared on your terms.' },@@@ -108,4 +117,4 @@ function createDiscoveryRoutes(ctx) {return r;}--module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };++module.exports = { createDiscoveryRoutes, homeJsonLd, homeDescription, publicPages, DESCRIPTION, SITE_NAME };@@@ -33,8 +33,9 @@ const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');const cache = require('openvibe-shared/cache-policy');++const seo = require('openvibe-shared/seo');const { asyncRouter } = require('./router');--const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');++const { createDiscoveryRoutes, homeJsonLd, homeDescription } = require('./discovery');const { sameOrigin } = require('./principal');const { html, raw, table, notice, time, badge } = require('../render/html');const { send } = require('../render/layout');@@@ -145,21 +146,39 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}}, 401));// ── Home ─────────────────────────────────────────────────++ // What the drive is, what a free account gets (the limits this service enforces, from config), how a backup runs++ // (the real command), questions, and where to go next on the network. No numbers here are made up: every one comes++ // from config or rules, so the page cannot promise more than the service does.++ const gb = (b) => rules.formatBytes(b).replace(/\.0 /, ' '); // "2 GB", not "2.0 GB"++ const homeFaq = () => [++ { q: 'Is OpenVibe.Download free?', a: `Yes. A free OpenVibe account gets ${gb(config.quotas.bytes)} of storage, files up to ${gb(config.quotas.maxFileBytes)} each and ${config.quotas.uploadsPerDay} uploads a day. There are no ads.` },++ { q: 'Who can open a file I share?', a: `Only the people you choose: named OpenVibe accounts, anyone signed in, or anyone with the link on a public download page. Every link expires in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, and you can revoke it at any time.` },++ { q: 'How do backups work?', a: 'Make a sync key on the Backups page and run the openvibe-download command on your computer, once or on a schedule. It sends only new or changed files into a folder of your drive, and it never deletes anything there unless you ask it to.' },++ drops.enabled()++ ? { q: 'Can I upload without an account?', a: `Yes. A file up to ${gb(drops.config.maxBytes)} gets a public download page and a delete key, and is deleted ${drops.config.idleDays} days after its last download.` }++ : { q: 'Can I upload without an account?', a: 'No. Uploading needs a signed-in OpenVibe account, so every file has an owner who can share it, take it back and delete it.' },++ { q: 'Where are my files kept?', a: 'In OpenVibe.Media, the network\'s own media service. Deleting a file here asks Media to delete it too, and deleting your OpenVibe account deletes your files.' },++ { q: 'What is not allowed?', a: 'Illegal content, malware, and anything shared to harm someone. Every share page has a Report form, and staff act on reports. The safety rules spell it all out.' },++ ];r.get('/', (req, res) => {const hero = showcase.hero({// One sentence in the heading (it read "OpenVibe.Download Your files, …"), the tagline not said twice.eyebrow: `${SITE_NAME} · a private drive`,title: 'Your files,',accent: 'shared on your terms.',-- lede: `A private drive for your files, ${rules.formatBytes(config.quotas.bytes)} free, with share links you control: for named people, for anyone signed in, or a public download page anyone with the link can use.${drops.enabled() ? ` No account? Upload a file up to ${rules.formatBytes(drops.config.maxBytes)} below and get a public link.` : ' Uploading needs a signed-in OpenVibe account.'}`,++ lede: `A private drive with ${gb(config.quotas.bytes)} free. Share any file with a link you control: for named people, for anyone signed in, or a public download page. Back up a folder from your computer with one command.${drops.enabled() ? ` No account? Upload a file up to ${gb(drops.config.maxBytes)} below and get a public link.` : ''}`,actions: signedIn(req)-- ? [{ label: 'Your files', href: '/files', primary: true }, { label: 'The safety rules', href: '/safety' }]-- : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Ffiles', primary: true }, { label: 'The safety rules', href: '/safety' }],-- note: 'The bytes live in OpenVibe.Media. This service holds the names, the folders and the share links.',++ ? [{ label: 'Your files', href: '/files', primary: true }, { label: 'Back up a folder', href: '/sync' }]++ : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Ffiles', primary: true }, { label: 'How backups work', href: '#sc-back-up-a-folder-from-your-computer' }],++ note: 'Open source, no ads. The bytes live in OpenVibe.Media; this service holds the names, the folders and the share links.',});++ const faq = homeFaq();page(req, res, {index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,-- jsonLd: homeJsonLd(config),++ // At most 60 characters (search results cut the rest); the layout adds no suffix to a title this long.++ title: `${SITE_NAME}: a free ${gb(config.quotas.bytes)} drive with share links`,++ description: homeDescription(config),++ jsonLd: [...homeJsonLd(config), seo.jsonLd.faq(faq)],styles: [showcase.STYLESHEET],scripts: drops.enabled() && !signedIn(req) ? ['js/drop.js'] : [],body: html`${raw(hero)}@@@ -168,12 +187,12 @@ ${raw(showcase.features({title: 'What you get',lede: 'A drive, not a locker: everything here is yours, and everything you hand out can be taken back.',items: [-- { icon: 'ov:upload', title: 'Your own drive', text: `Folders and files, private to you: ${rules.formatBytes(config.quotas.bytes)} stored, up to ${rules.formatBytes(config.quotas.maxFileBytes)} in one file.` },++ { icon: 'ov:upload', title: 'Your own drive', text: `Folders and files, private to you: ${gb(config.quotas.bytes)} stored, up to ${gb(config.quotas.maxFileBytes)} in one file.` },{ icon: 'ov:link', title: 'Share links', text: `One link per file or folder, expiring in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, revocable at any moment, with its downloads counted.` },-- { icon: 'ov:account', title: 'You choose who opens it', text: 'Named OpenVibe accounts, anyone signed in, or a public download page for anyone with the link. Uploading always needs a signed-in person.' },++ { icon: 'ov:account', title: 'You choose who opens it', text: 'Named OpenVibe accounts, anyone signed in, or a public download page for anyone with the link.' },{ icon: 'ov:shield', title: 'Reports that act', text: REPORT_NOTE },-- { icon: 'ov:tools', title: 'Backups and an API', text: 'Keep a folder on your computer backed up with the openvibe-download command, and do everything else over /api/v1.' },-- { icon: 'ov:db', title: 'Bytes in OpenVibe.Media', text: 'Files are objects in the network\'s own media service; deleting one here asks Media to delete it too.' },++ { icon: 'ov:drive', title: 'Backups', text: 'Keep a folder on your computer copied into your drive with the openvibe-download command. It sends only what is new or changed.' },++ { icon: 'ov:plug', title: 'An API', text: 'Everything the pages do, over /api/v1 with your OpenVibe sign-in: upload, list, share and revoke.' },],}))}${raw(showcase.steps({@@@ -181,11 +200,40 @@ ${raw(showcase.steps({lede: 'Four steps, and the last one is the point.',items: [{ title: 'Sign in', text: 'OpenVibe.Network signs you in. Your session stays in an httpOnly cookie on this site.' },-- { title: 'Upload', text: 'One request for a file under the single-request limit; a big file goes up in parts with a progress meter. Both need a signed-in person.' },++ { title: 'Upload', text: `Drop a file on Your files. A big one goes up in parts with a progress meter, up to ${gb(config.quotas.maxFileBytes)}.` },{ title: 'Share', text: `Make a link that expires in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, public or for signed-in people, and optionally name the OpenVibe usernames allowed to open it.` },{ title: 'Take it back', text: 'Revoke a link and it stops working at once. Delete a file and its shares go with it, and Media is asked to drop the bytes.' },],}))}++${raw(showcase.code({++ title: 'Back up a folder from your computer',++ lede: `Make a sync key on the Backups page, then run this on your computer (Node.js 20 or newer). It copies only new and changed files, up to ${config.quotas.syncUploadsPerDay.toLocaleString('en-US')} a day, and the Backups page explains its options.`,++ samples: [{ label: 'Terminal', lang: 'sh', code: `export OPENVIBE_DOWNLOAD_KEY=<your sync key>\n${SYNC_COMMAND} sync ~/Documents --to Backups/laptop` }],++ }))}++${raw(showcase.limits({++ title: 'What a free account gets',++ lede: 'The limits this drive enforces for every account, the same on the pages and the API.',++ columns: ['Free account'],++ rows: [++ { label: 'Storage', values: [gb(config.quotas.bytes)] },++ { label: 'Largest file', values: [gb(config.quotas.maxFileBytes)] },++ { label: 'Uploads a day', values: [String(config.quotas.uploadsPerDay)] },++ { label: 'Backup uploads a day', values: [config.quotas.syncUploadsPerDay.toLocaleString('en-US')] },++ { label: 'Share links at a time', values: [String(config.quotas.maxShares)] },++ { label: 'A share link lasts', values: [`${rules.EXPIRY_MIN_HOURS} hour${rules.EXPIRY_MIN_HOURS === 1 ? '' : 's'} to ${rules.EXPIRY_MAX_HOURS / 24} days`] },++ ...(drops.enabled() ? [{ label: 'Without an account', values: [`${gb(drops.config.maxBytes)} a file, ${drops.config.perDay} a day`] }] : []),++ ],++ }))}++<section class="sc-sec" 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>++${raw(showcase.nextSteps({++ title: 'More on OpenVibe',++ items: [++ { to: 'video', what: 'Upload a video', text: 'With AI captions and chapters, on your own channel.', href: `${config.video.url}/`, from: 'download' },++ { to: 'pics', what: 'Share a picture', text: 'A link to share or embed anywhere, no ads.', href: `${config.pics.url}/`, from: 'download' },++ { to: 'live', what: 'Go live from your browser', text: 'No OBS and no follower minimum.', href: 'https://openvibe.live/', from: 'download' },++ { to: 'chat', what: 'Talk it over', text: 'Rooms, servers and voice channels.', href: 'https://openvibe.chat/', from: 'download' },++ ],++ }))}${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is not allowed, how reports work, and who to write to.', actions: [{ label: 'The safety rules', href: '/safety' }, { label: 'What shipped', href: '/updates' }] }))}`,});});@@@ -892,7 +940,7 @@ ${notice(drops.enabled() ? 'This is a drive with share links, and uploads withou<li><b>Uploads come from a signed-in OpenVibe account</b>, or a sync key its owner made on <a href="/sync">/sync</a> for their own backups${drops.enabled() ? html`, or without an account: up to ${rules.formatBytes(drops.config.maxBytes)}, ${drops.config.perDay} a day per address, with a public page, a delete key shown once, and deleted ${drops.config.idleDays} days after the last download` : ''}. No app or service token can post a file here.</li><li><b>Every file is private to the person who uploaded it.</b> Nobody else sees it in a list, and nobody else can download it.</li><li><b>A share link is for signed-in people unless its owner makes it public.</b> A signed-in link needs the person opening it to be signed in; a public link opens for anyone who has it. Every share page says who shared it and has a Report form.</li>--<li><b>A share can be limited to named OpenVibe usernames</b>, expire in an hour to seven days, and be revoked at any moment — after which it stops working at once.</li>++<li><b>A share can be limited to named OpenVibe usernames</b>, expire in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, and be revoked at any moment — after which it stops working at once.</li><li><b>Downloads never happen in a page.</b> A file is always sent as an attachment with a no-sniff header, and a type that is not on a short safe list is sent as an opaque blob. Uploaded HTML and SVG are never rendered.</li></ul></section><section class="sc-sec" aria-labelledby="h-reports"><h2 id="h-reports">How reports work</h2>@@@ -199,16 +199,33 @@ function createPicsRoutes(ctx) {}// ── Home ────────────────────────────────────────────────++ // Pictures here are unlisted (nobody's picture is ever listed), so the home shows no gallery of other people's++ // pictures. It shows how it works, the links a picture gets, the sizes and formats, the API, the limits this++ // service enforces (from config) and questions; a signed-in person also sees their own latest pictures.++ const FORMAT_NAMES = 'JPEG, PNG, GIF, WebP or AVIF';++ const sizeText = (b) => formatBytes(b).replace(/\.0 /, ' ');++ const homeFaq = () => [++ { q: 'Is OpenVibe.Pics free?', a: `Yes, and there are no ads. 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: '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 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, no ads, with location and camera details removed.';++ const faq = homeFaq();return page(req, res, {index: true,-- description: 'Upload a picture and get a link to share or embed anywhere. Albums, no ads, open source, with location and camera details removed from every picture.',-- // What the site is, for search engines (as the Download home says it).++ title: `${SITE_NAME}: share pictures with a link, no ads`,++ description,jsonLd: [-- seo.jsonLd.website({ name: SITE_NAME, url: base, description: 'Upload a picture and get a link to share or embed anywhere. Albums, no ads, open source, with location and camera details removed from every picture.' }),-- seo.jsonLd.webPage({ name: SITE_NAME, url: `${base}/`, description: 'Upload a picture and get a link to share or embed anywhere. Albums, no ads, open source, with location and camera details removed from every picture.', siteUrl: base }),++ seo.jsonLd.website({ name: SITE_NAME, url: base, description }),++ 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.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>@@@ -216,14 +233,46 @@ function createPicsRoutes(ctx) {<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 ads, no feed, 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>` : ''}++<section class="ovp-section" aria-labelledby="h-how"><h2 id="h-how">How it works</h2><ol class="ovp-steps">++<li><h3>Drop a picture</h3><p>Choose, drag or paste ${FORMAT_NAMES}, up to ${sizeText(cfg.maxImageBytes)}. Several at once works too.</p></li>++<li><h3>Get your links</h3><p>A page to share, a direct link, and Markdown, BBCode and HTML ready to copy.</p></li>++<li><h3>Use it anywhere</h3><p>Post it in a forum or a chat, embed it on your site, or put it in an album. Delete it whenever you like.</p></li>++</ol></section><section class="ovp-section ovp-features" aria-label="What you get"><div class="ovp-feature"><h3>Links that work everywhere</h3><p>A page to share, a direct link for <code><img></code>, and Markdown, BBCode and HTML ready to copy.</p></div><div class="ovp-feature"><h3>Unlisted by default</h3><p>Only people with the link see a picture. Nothing is listed, ranked or indexed.</p></div><div class="ovp-feature"><h3>Clean files</h3><p>Every picture is re-encoded, and its GPS position, camera serial and other hidden details are removed before anyone sees it.</p></div><div class="ovp-feature"><h3>Albums</h3><p>Put pictures together and share the album with one link.</p></div>--<div class="ovp-feature"><h3>Any size, any format</h3><p>Every picture also comes at 320 to 2560 pixels wide, as WebP, AVIF, JPEG or PNG, each with a link of its own.</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>++<section class="ovp-section" aria-labelledby="h-links"><h2 id="h-links">The links a picture gets</h2>++<p class="ovp-section-lede">Each picture has a short name of eight letters and digits. These are the links it answers at:</p>++<dl class="ovp-links">++<div><dt>Its page</dt><dd><code>${base}/i/<name></code></dd></div>++<div><dt>The picture itself, in the format you uploaded</dt><dd><code>${base}/i/<name>.jpg</code></dd></div>++<div><dt>Another width or format</dt><dd><code>${base}/i/<name>/w/640.avif</code></dd></div>++<div><dt>Markdown, with your alt text</dt><dd><code></code></dd></div>++</dl></section>++<section class="ovp-section" aria-labelledby="h-api"><h2 id="h-api">Upload from your own app</h2>++<p class="ovp-section-lede">The API takes the picture as the request body, with an OpenVibe sign-in token, and answers with its links.</p>++<pre class="ovp-code" tabindex="0" aria-label="Upload with curl"><code>curl -X POST ${base}/api/v1/images \\++ -H "Authorization: Bearer $OPENVIBE_TOKEN" \\++ -H "Content-Type: image/jpeg" \\++ --data-binary @photo.jpg</code></pre></section>++<section class="ovp-section" aria-labelledby="h-limits"><h2 id="h-limits">What a free account gets</h2>++<div class="ovp-table-wrap" tabindex="0" role="region" aria-labelledby="h-limits"><table class="ovp-size-table ovp-limits"><thead><tr><th scope="col">Limit</th><th scope="col">Signed in</th>${cfg.anonymous ? html`<th scope="col">Without an account</th>` : ''}</tr></thead><tbody>++<tr><th scope="row">Largest picture</th><td>${sizeText(cfg.maxImageBytes)}</td>${cfg.anonymous ? html`<td>${sizeText(cfg.anonymousMaxImageBytes)}</td>` : ''}</tr>++<tr><th scope="row">Pictures a day</th><td>${cfg.imagesPerDay}</td>${cfg.anonymous ? html`<td>${cfg.anonymousPerDay}</td>` : ''}</tr>++<tr><th scope="row">Storage</th><td>${sizeText(cfg.quotaBytes)}</td>${cfg.anonymous ? html`<td>Kept ${cfg.anonymousIdleDays} days after the last view</td>` : ''}</tr>++</tbody></table></div></section>++<section class="ovp-section" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2><div class="faq">${faq.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</div></section>++<section class="ovp-section" aria-labelledby="h-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>`,});});@@@ -524,7 +573,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({ disallow: ['/i/', '/a/', '/mine', '/albums', '/staff', '/api/', '/auth/', '/upload'] })}`));++ .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.++ 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}` })))));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, no ads.',@@@ -25,6 +25,9 @@ const TAGLINE = 'Your files, shared on your terms.';const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');// The product's own navigation: one entry per public page it serves.const NAV = [++ { label: 'Your files', href: '/files' },++ { label: 'Backups', href: '/sync' },++ { label: 'Safety', href: '/safety' },{ label: 'What shipped', href: '/updates' },];@@@ -38,11 +38,18 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#maiassert.strictEqual(r.status, 200);const head = r.text.slice(0, r.text.indexOf('</head>'));assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');-- assert.ok(head.includes('<title>OpenVibe.Download: Your files, shared on your terms.</title>'), 'home title');++ assert.ok(head.includes('<title>OpenVibe.Download: a free 25 GB drive with share links</title>'), `home title: ${(/<title>([^<]*)/.exec(head) || [])[1]}`);++ assert.match(head, /<meta name="description" content="A private drive with 25 GB free: upload files up to 2 GB, share them with links you can take back, and back up a folder from your computer\.">/, 'a description that says what it does, with the real numbers');assert.ok(head.includes('<link rel="canonical" href="https://openvibe.download/">'), 'canonical');assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);-- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');++ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'FAQPage'], 'the home JSON-LD');++ // FAQPage only for questions shown on the page: every question in it is a <summary> in the body.++ const faq = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).find((x) => x['@type'] === 'FAQPage');++ const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));++ assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'the questions on the page');++ assert.ok(r.text.includes('openvibe-download sync ~/Documents --to Backups/laptop'), 'the backup command, as the Backups page gives it');++ assert.ok(!r.text.includes('2.0 GB'), 'sizes as people write them');// openvibe-shared/app-icon slugs the site id to lower-case letters (media-hub → mediahub) in data-ov-icon.for (const tag of ['<meta property="og:title"', 'data-ov-icon="mediahub"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {assert.ok(head.includes(tag), `head has ${tag}`);@@@ -62,7 +69,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#maiassert.strictEqual(r.status, 200, r.text.slice(0, 300));assert.match(r.headers.get('content-type'), /^text\/html/);assert.ok(r.text.includes('OpenVibe.Download'), 'the site name is on the page');-- assert.ok(r.text.includes('Your files, shared on your terms.'), 'the tagline is on the page');++ assert.ok(r.text.includes('A private drive with 25 GB free.'), 'the lede says what a free account gets');const h1 = (/<h1[^>]*>([\s\S]*?)<\/h1>/.exec(r.text) || [])[1] || '';assert.strictEqual(h1.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(), 'Your files, shared on your terms.', 'the heading is one sentence');assert.ok(r.text.includes('/shared/showcase.css'), 'the showcase stylesheet');@@@ -14,8 +14,9 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%// headroom; file counts are the measured counts and external files stay at 0.const BUDGETS = {-- htmlRawKB: 29.7, // measured 27.5 (real glyphs for the db, link and shield icons, shared 2.21.1); was 27.5-- htmlBrotliKB: 7.7, // 7.1 (real glyphs for the db, link and shield icons, shared 2.21.1); was 6.9++ htmlRawKB: 43.5, // measured 39.4 on 10-22: the home gained the backup command, the limits table, six questions (and their++ // FAQPage JSON-LD) and next steps on the network; was 29.7 at 27.5++ htmlBrotliKB: 10.4, // 9.4 on 10-22 (the same sections); was 7.7 at 7.1jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 264, // 239.8jsBrotliKB: 62.5, // 56.5@@@ -76,6 +76,24 @@ function multipart(fields, file) {assert.strictEqual((await t.get('/robots.txt', { headers: PICS })).text.includes('Disallow: /i/'), true, 'pictures are not crawled');});++ await check('the home: how it works, the links, the API, the limits from config, questions that match its FAQPage', async () => {++ const home = (await t.get('/', { headers: PICS })).text;++ assert.strictEqual((home.match(/<h1[\s>]/g) || []).length, 1, 'one h1');++ assert.match(home, /<title>OpenVibe\.Pics: share pictures with a link, no ads<\/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');++ assert.ok(home.includes('<td>20 MB</td>') && home.includes('<td>200</td>') && home.includes('<td>5 GB</td>'), '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');++ const ld = [...home.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1])));++ const faq = ld.find((x) => x['@type'] === 'FAQPage');++ const shown = [...home.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));++ assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the FAQPage is on the page');++ const map = (await t.get('/sitemap.xml', { headers: PICS })).text;++ assert.ok(map.includes('<loc>https://openvibe.pics/</loc>') && map.includes('<loc>https://openvibe.pics/dmca</loc>'), 'the sitemap lists the home and the rules');++ assert.ok(!map.includes('/i/'), 'and never a picture');++ assert.ok((await t.get('/robots.txt', { headers: PICS })).text.includes('Sitemap: https://openvibe.pics/sitemap.xml'), 'robots.txt names it');++ });++await check('signed out, uploads are refused until MEDIAHUB_PICS_ANONYMOUS is on', async () => {const r = await t.get('/api/v1/images', { method: 'POST', body: jpegWithSecrets, headers: { ...SAME, 'content-type': 'image/jpeg' } });assert.strictEqual(r.status, 401);
@@ -63,3 +63,9 @@ button.sc-btn.sc-primary { border: 0; }
@@ -63,3 +63,9 @@ button.sc-btn.sc-primary { border: 0; }
.field { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; max-width: 520px; }
.field { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; max-width: 520px; }
.field input[type=text], .field input[type=search], .field select, .field textarea, form.inline input[type=text] { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: inherit; }
.field input[type=text], .field input[type=search], .field select, .field textarea, form.inline input[type=text] { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: inherit; }
.field-row { display: flex; flex-wrap: wrap; gap: 12px; }
.field-row { display: flex; flex-wrap: wrap; gap: 12px; }
+/* Questions on a home page: each a disclosure, answer under its question */
+.faq { display: grid; gap: 8px; max-width: 860px; }
+.faq details { border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, var(--bg-secondary, #101828)); }
+.faq summary { cursor: pointer; padding: 12px 16px; font-weight: 700; }
+.faq details[open] summary { border-bottom: 1px solid var(--border, #1f2d47); }
+.faq p { margin: 0; padding: 12px 16px 14px; color: var(--text-secondary, #96a7c2); line-height: 1.6; }
@@ -146,3 +146,25 @@
@@ -146,3 +146,25 @@
.ovp-size-table thead th { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-faint, #7386a3); font-weight: 700; }
.ovp-size-table thead th { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-faint, #7386a3); font-weight: 700; }
.ovp-size-table tbody th { font-weight: 700; }
.ovp-size-table tbody th { font-weight: 700; }
.ovp-size-table a { display: inline-block; min-width: 44px; padding: 4px 0; }
.ovp-size-table a { display: inline-block; min-width: 44px; padding: 4px 0; }
+
+/* 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-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; }
+.ovp-steps h3 { margin: 0 0 6px; font-size: 1rem; }
+.ovp-steps p { margin: 0; color: var(--ovp-dim); font-size: .92rem; }
+.ovp-links { display: grid; gap: 8px; margin: 0; }
+.ovp-links > div { display: grid; grid-template-columns: minmax(0, 15rem) minmax(0, 1fr); gap: 4px 14px; align-items: baseline; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--ovp-line); background: var(--ovp-card); }
+.ovp-links dt { font-weight: 700; font-size: .9rem; }
+.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-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; }
+.ovp-more { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }
+.ovp-more a { display: grid; gap: 2px; height: 100%; box-sizing: border-box; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--ovp-line); background: var(--ovp-card); color: inherit !important; text-decoration: none; }
+.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); } }
@@ -14,12 +14,12 @@ const cache = require('openvibe-shared/cache-policy');
@@ -14,12 +14,12 @@ const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Download';
const SITE_NAME = 'OpenVibe.Download';
-const DESCRIPTION = 'OpenVibe.Download — Your files, shared on your terms.';
+const DESCRIPTION = 'A private drive on your OpenVibe account: upload files, share them with links you can take back, and back up a folder from your computer.';
// /s/ (with the slash) rather than /s, or the rule would also swallow /safety, which is a public page.
// /s/ (with the slash) rather than /s, or the rule would also swallow /safety, which is a public page.
const DISALLOW = ['/auth/', '/api/', '/files', '/s/', '/staff'];
const DISALLOW = ['/auth/', '/api/', '/files', '/s/', '/staff'];
const PAGE_TEXT = {
const PAGE_TEXT = {
- '/': ['OpenVibe.Download home', 'OpenVibe.Download: Your files, shared on your terms. A private drive for a signed-in OpenVibe account, with share links that expire, can be revoked and need the downloader to be signed in too.'],
+ '/': ['OpenVibe.Download home', 'OpenVibe.Download: Your files, shared on your terms. A private drive for a signed-in OpenVibe account, with share links that expire and can be revoked (for named people, anyone signed in, or a public download page), folder backups with the openvibe-download command, and an API.'],
'/safety': ['Safety rules', 'What OpenVibe.Download allows and does not, who can upload and download, how reports work, and who to write to about abuse or copyright.'],
'/safety': ['Safety rules', 'What OpenVibe.Download allows and does not, who can upload and download, how reports work, and who to write to about abuse or copyright.'],
'/updates': ['What shipped on OpenVibe.Download', 'This site\'s update log, from the network changelog feed.'],
'/updates': ['What shipped on OpenVibe.Download', 'This site\'s update log, from the network changelog feed.'],
};
};
@@ -32,12 +32,21 @@ function siteUpdated() {
@@ -32,12 +32,21 @@ function siteUpdated() {
try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }
try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }
}
}
+/** The home's meta description, with the free storage this deployment really gives (config.quotas). At most 160 characters. */
+function homeDescription(config) {
+ const gb = (b) => `${Math.round(Number(b) / (1024 ** 3))} GB`;
+ const q = config.quotas || {};
+ if (!q.bytes || !q.maxFileBytes) return DESCRIPTION;
+ return `A private drive with ${gb(q.bytes)} free: upload files up to ${gb(q.maxFileBytes)}, share them with links you can take back, and back up a folder from your computer.`.slice(0, 160);
+}
+
function homeJsonLd(config) {
function homeJsonLd(config) {
const site = String(config.baseUrl).replace(/\/+$/, '');
const site = String(config.baseUrl).replace(/\/+$/, '');
+ const description = homeDescription(config);
return [
return [
- seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
- seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe' }),
- seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
+ seo.jsonLd.website({ name: SITE_NAME, url: site, description }),
+ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description, category: 'BusinessApplication', keywords: 'file storage, file sharing, backup, cloud drive, openvibe' }),
+ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description, siteUrl: site }),
];
];
}
}
@@ -67,8 +76,8 @@ function createDiscoveryRoutes(ctx) {
@@ -67,8 +76,8 @@ function createDiscoveryRoutes(ctx) {
r.get('/llms.txt', (_req, res) => {
r.get('/llms.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
- summary: 'OpenVibe.Download: Your files, shared on your terms.',
- details: 'Every page is server-rendered and readable without JavaScript.',
+ summary: 'OpenVibe.Download: a private drive on your OpenVibe account, with share links you control and folder backups.',
+ details: 'Upload files to your own drive, share a file or folder with named OpenVibe accounts, anyone signed in, or a public download page (links expire and can be revoked), and keep a folder on your computer backed up with the openvibe-download command (a sync key from /sync). Every page is server-rendered and readable without JavaScript.',
sections: [
sections: [
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Download', url: abs('/'), note: 'Your files, shared on your terms.' },
{ title: 'OpenVibe.Download', url: abs('/'), note: 'Your files, shared on your terms.' },
@@ -108,4 +117,4 @@ function createDiscoveryRoutes(ctx) {
@@ -108,4 +117,4 @@ function createDiscoveryRoutes(ctx) {
return r;
return r;
}
}
-module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };
+module.exports = { createDiscoveryRoutes, homeJsonLd, homeDescription, publicPages, DESCRIPTION, SITE_NAME };
@@ -33,8 +33,9 @@ const ovServe = require('openvibe-shared/serve');
@@ -33,8 +33,9 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
+const seo = require('openvibe-shared/seo');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
-const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
+const { createDiscoveryRoutes, homeJsonLd, homeDescription } = require('./discovery');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
const { html, raw, table, notice, time, badge } = require('../render/html');
const { html, raw, table, notice, time, badge } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
@@ -145,21 +146,39 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
@@ -145,21 +146,39 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
}, 401));
}, 401));
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
+ // What the drive is, what a free account gets (the limits this service enforces, from config), how a backup runs
+ // (the real command), questions, and where to go next on the network. No numbers here are made up: every one comes
+ // from config or rules, so the page cannot promise more than the service does.
+ const gb = (b) => rules.formatBytes(b).replace(/\.0 /, ' '); // "2 GB", not "2.0 GB"
+ const homeFaq = () => [
+ { q: 'Is OpenVibe.Download free?', a: `Yes. A free OpenVibe account gets ${gb(config.quotas.bytes)} of storage, files up to ${gb(config.quotas.maxFileBytes)} each and ${config.quotas.uploadsPerDay} uploads a day. There are no ads.` },
+ { q: 'Who can open a file I share?', a: `Only the people you choose: named OpenVibe accounts, anyone signed in, or anyone with the link on a public download page. Every link expires in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, and you can revoke it at any time.` },
+ { q: 'How do backups work?', a: 'Make a sync key on the Backups page and run the openvibe-download command on your computer, once or on a schedule. It sends only new or changed files into a folder of your drive, and it never deletes anything there unless you ask it to.' },
+ drops.enabled()
+ ? { q: 'Can I upload without an account?', a: `Yes. A file up to ${gb(drops.config.maxBytes)} gets a public download page and a delete key, and is deleted ${drops.config.idleDays} days after its last download.` }
+ : { q: 'Can I upload without an account?', a: 'No. Uploading needs a signed-in OpenVibe account, so every file has an owner who can share it, take it back and delete it.' },
+ { q: 'Where are my files kept?', a: 'In OpenVibe.Media, the network\'s own media service. Deleting a file here asks Media to delete it too, and deleting your OpenVibe account deletes your files.' },
+ { q: 'What is not allowed?', a: 'Illegal content, malware, and anything shared to harm someone. Every share page has a Report form, and staff act on reports. The safety rules spell it all out.' },
+ ];
r.get('/', (req, res) => {
r.get('/', (req, res) => {
const hero = showcase.hero({
const hero = showcase.hero({
// One sentence in the heading (it read "OpenVibe.Download Your files, …"), the tagline not said twice.
// One sentence in the heading (it read "OpenVibe.Download Your files, …"), the tagline not said twice.
eyebrow: `${SITE_NAME} · a private drive`,
eyebrow: `${SITE_NAME} · a private drive`,
title: 'Your files,',
title: 'Your files,',
accent: 'shared on your terms.',
accent: 'shared on your terms.',
- lede: `A private drive for your files, ${rules.formatBytes(config.quotas.bytes)} free, with share links you control: for named people, for anyone signed in, or a public download page anyone with the link can use.${drops.enabled() ? ` No account? Upload a file up to ${rules.formatBytes(drops.config.maxBytes)} below and get a public link.` : ' Uploading needs a signed-in OpenVibe account.'}`,
+ lede: `A private drive with ${gb(config.quotas.bytes)} free. Share any file with a link you control: for named people, for anyone signed in, or a public download page. Back up a folder from your computer with one command.${drops.enabled() ? ` No account? Upload a file up to ${gb(drops.config.maxBytes)} below and get a public link.` : ''}`,
actions: signedIn(req)
actions: signedIn(req)
- ? [{ label: 'Your files', href: '/files', primary: true }, { label: 'The safety rules', href: '/safety' }]
- : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Ffiles', primary: true }, { label: 'The safety rules', href: '/safety' }],
- note: 'The bytes live in OpenVibe.Media. This service holds the names, the folders and the share links.',
+ ? [{ label: 'Your files', href: '/files', primary: true }, { label: 'Back up a folder', href: '/sync' }]
+ : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Ffiles', primary: true }, { label: 'How backups work', href: '#sc-back-up-a-folder-from-your-computer' }],
+ note: 'Open source, no ads. The bytes live in OpenVibe.Media; this service holds the names, the folders and the share links.',
});
});
+ const faq = homeFaq();
page(req, res, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
- jsonLd: homeJsonLd(config),
+ // At most 60 characters (search results cut the rest); the layout adds no suffix to a title this long.
+ title: `${SITE_NAME}: a free ${gb(config.quotas.bytes)} drive with share links`,
+ description: homeDescription(config),
+ jsonLd: [...homeJsonLd(config), seo.jsonLd.faq(faq)],
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
scripts: drops.enabled() && !signedIn(req) ? ['js/drop.js'] : [],
scripts: drops.enabled() && !signedIn(req) ? ['js/drop.js'] : [],
body: html`${raw(hero)}
body: html`${raw(hero)}
@@ -168,12 +187,12 @@ ${raw(showcase.features({
@@ -168,12 +187,12 @@ ${raw(showcase.features({
title: 'What you get',
title: 'What you get',
lede: 'A drive, not a locker: everything here is yours, and everything you hand out can be taken back.',
lede: 'A drive, not a locker: everything here is yours, and everything you hand out can be taken back.',
items: [
items: [
- { icon: 'ov:upload', title: 'Your own drive', text: `Folders and files, private to you: ${rules.formatBytes(config.quotas.bytes)} stored, up to ${rules.formatBytes(config.quotas.maxFileBytes)} in one file.` },
+ { icon: 'ov:upload', title: 'Your own drive', text: `Folders and files, private to you: ${gb(config.quotas.bytes)} stored, up to ${gb(config.quotas.maxFileBytes)} in one file.` },
{ icon: 'ov:link', title: 'Share links', text: `One link per file or folder, expiring in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, revocable at any moment, with its downloads counted.` },
{ icon: 'ov:link', title: 'Share links', text: `One link per file or folder, expiring in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, revocable at any moment, with its downloads counted.` },
- { icon: 'ov:account', title: 'You choose who opens it', text: 'Named OpenVibe accounts, anyone signed in, or a public download page for anyone with the link. Uploading always needs a signed-in person.' },
+ { icon: 'ov:account', title: 'You choose who opens it', text: 'Named OpenVibe accounts, anyone signed in, or a public download page for anyone with the link.' },
{ icon: 'ov:shield', title: 'Reports that act', text: REPORT_NOTE },
{ icon: 'ov:shield', title: 'Reports that act', text: REPORT_NOTE },
- { icon: 'ov:tools', title: 'Backups and an API', text: 'Keep a folder on your computer backed up with the openvibe-download command, and do everything else over /api/v1.' },
- { icon: 'ov:db', title: 'Bytes in OpenVibe.Media', text: 'Files are objects in the network\'s own media service; deleting one here asks Media to delete it too.' },
+ { icon: 'ov:drive', title: 'Backups', text: 'Keep a folder on your computer copied into your drive with the openvibe-download command. It sends only what is new or changed.' },
+ { icon: 'ov:plug', title: 'An API', text: 'Everything the pages do, over /api/v1 with your OpenVibe sign-in: upload, list, share and revoke.' },
],
],
}))}
}))}
${raw(showcase.steps({
${raw(showcase.steps({
@@ -181,11 +200,40 @@ ${raw(showcase.steps({
@@ -181,11 +200,40 @@ ${raw(showcase.steps({
lede: 'Four steps, and the last one is the point.',
lede: 'Four steps, and the last one is the point.',
items: [
items: [
{ title: 'Sign in', text: 'OpenVibe.Network signs you in. Your session stays in an httpOnly cookie on this site.' },
{ title: 'Sign in', text: 'OpenVibe.Network signs you in. Your session stays in an httpOnly cookie on this site.' },
- { title: 'Upload', text: 'One request for a file under the single-request limit; a big file goes up in parts with a progress meter. Both need a signed-in person.' },
+ { title: 'Upload', text: `Drop a file on Your files. A big one goes up in parts with a progress meter, up to ${gb(config.quotas.maxFileBytes)}.` },
{ title: 'Share', text: `Make a link that expires in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, public or for signed-in people, and optionally name the OpenVibe usernames allowed to open it.` },
{ title: 'Share', text: `Make a link that expires in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, public or for signed-in people, and optionally name the OpenVibe usernames allowed to open it.` },
{ title: 'Take it back', text: 'Revoke a link and it stops working at once. Delete a file and its shares go with it, and Media is asked to drop the bytes.' },
{ title: 'Take it back', text: 'Revoke a link and it stops working at once. Delete a file and its shares go with it, and Media is asked to drop the bytes.' },
],
],
}))}
}))}
+${raw(showcase.code({
+ title: 'Back up a folder from your computer',
+ lede: `Make a sync key on the Backups page, then run this on your computer (Node.js 20 or newer). It copies only new and changed files, up to ${config.quotas.syncUploadsPerDay.toLocaleString('en-US')} a day, and the Backups page explains its options.`,
+ samples: [{ label: 'Terminal', lang: 'sh', code: `export OPENVIBE_DOWNLOAD_KEY=<your sync key>\n${SYNC_COMMAND} sync ~/Documents --to Backups/laptop` }],
+ }))}
+${raw(showcase.limits({
+ title: 'What a free account gets',
+ lede: 'The limits this drive enforces for every account, the same on the pages and the API.',
+ columns: ['Free account'],
+ rows: [
+ { label: 'Storage', values: [gb(config.quotas.bytes)] },
+ { label: 'Largest file', values: [gb(config.quotas.maxFileBytes)] },
+ { label: 'Uploads a day', values: [String(config.quotas.uploadsPerDay)] },
+ { label: 'Backup uploads a day', values: [config.quotas.syncUploadsPerDay.toLocaleString('en-US')] },
+ { label: 'Share links at a time', values: [String(config.quotas.maxShares)] },
+ { label: 'A share link lasts', values: [`${rules.EXPIRY_MIN_HOURS} hour${rules.EXPIRY_MIN_HOURS === 1 ? '' : 's'} to ${rules.EXPIRY_MAX_HOURS / 24} days`] },
+ ...(drops.enabled() ? [{ label: 'Without an account', values: [`${gb(drops.config.maxBytes)} a file, ${drops.config.perDay} a day`] }] : []),
+ ],
+ }))}
+<section class="sc-sec" 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>
+${raw(showcase.nextSteps({
+ title: 'More on OpenVibe',
+ items: [
+ { to: 'video', what: 'Upload a video', text: 'With AI captions and chapters, on your own channel.', href: `${config.video.url}/`, from: 'download' },
+ { to: 'pics', what: 'Share a picture', text: 'A link to share or embed anywhere, no ads.', href: `${config.pics.url}/`, from: 'download' },
+ { to: 'live', what: 'Go live from your browser', text: 'No OBS and no follower minimum.', href: 'https://openvibe.live/', from: 'download' },
+ { to: 'chat', what: 'Talk it over', text: 'Rooms, servers and voice channels.', href: 'https://openvibe.chat/', from: 'download' },
+ ],
+ }))}
${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is not allowed, how reports work, and who to write to.', actions: [{ label: 'The safety rules', href: '/safety' }, { label: 'What shipped', href: '/updates' }] }))}`,
${raw(showcase.cta({ title: 'Before you upload', text: 'Read the rules: what is not allowed, how reports work, and who to write to.', actions: [{ label: 'The safety rules', href: '/safety' }, { label: 'What shipped', href: '/updates' }] }))}`,
});
});
});
});
@@ -892,7 +940,7 @@ ${notice(drops.enabled() ? 'This is a drive with share links, and uploads withou
@@ -892,7 +940,7 @@ ${notice(drops.enabled() ? 'This is a drive with share links, and uploads withou
<li><b>Uploads come from a signed-in OpenVibe account</b>, or a sync key its owner made on <a href="/sync">/sync</a> for their own backups${drops.enabled() ? html`, or without an account: up to ${rules.formatBytes(drops.config.maxBytes)}, ${drops.config.perDay} a day per address, with a public page, a delete key shown once, and deleted ${drops.config.idleDays} days after the last download` : ''}. No app or service token can post a file here.</li>
<li><b>Uploads come from a signed-in OpenVibe account</b>, or a sync key its owner made on <a href="/sync">/sync</a> for their own backups${drops.enabled() ? html`, or without an account: up to ${rules.formatBytes(drops.config.maxBytes)}, ${drops.config.perDay} a day per address, with a public page, a delete key shown once, and deleted ${drops.config.idleDays} days after the last download` : ''}. No app or service token can post a file here.</li>
<li><b>Every file is private to the person who uploaded it.</b> Nobody else sees it in a list, and nobody else can download it.</li>
<li><b>Every file is private to the person who uploaded it.</b> Nobody else sees it in a list, and nobody else can download it.</li>
<li><b>A share link is for signed-in people unless its owner makes it public.</b> A signed-in link needs the person opening it to be signed in; a public link opens for anyone who has it. Every share page says who shared it and has a Report form.</li>
<li><b>A share link is for signed-in people unless its owner makes it public.</b> A signed-in link needs the person opening it to be signed in; a public link opens for anyone who has it. Every share page says who shared it and has a Report form.</li>
-<li><b>A share can be limited to named OpenVibe usernames</b>, expire in an hour to seven days, and be revoked at any moment — after which it stops working at once.</li>
+<li><b>A share can be limited to named OpenVibe usernames</b>, expire in an hour to ${rules.EXPIRY_MAX_HOURS / 24} days, and be revoked at any moment — after which it stops working at once.</li>
<li><b>Downloads never happen in a page.</b> A file is always sent as an attachment with a no-sniff header, and a type that is not on a short safe list is sent as an opaque blob. Uploaded HTML and SVG are never rendered.</li>
<li><b>Downloads never happen in a page.</b> A file is always sent as an attachment with a no-sniff header, and a type that is not on a short safe list is sent as an opaque blob. Uploaded HTML and SVG are never rendered.</li>
</ul></section>
</ul></section>
<section class="sc-sec" aria-labelledby="h-reports"><h2 id="h-reports">How reports work</h2>
<section class="sc-sec" aria-labelledby="h-reports"><h2 id="h-reports">How reports work</h2>
@@ -199,16 +199,33 @@ function createPicsRoutes(ctx) {
@@ -199,16 +199,33 @@ function createPicsRoutes(ctx) {
}
}
// ── Home ────────────────────────────────────────────────
// ── Home ────────────────────────────────────────────────
+ // Pictures here are unlisted (nobody's picture is ever listed), so the home shows no gallery of other people's
+ // pictures. It shows how it works, the links a picture gets, the sizes and formats, the API, the limits this
+ // service enforces (from config) and questions; a signed-in person also sees their own latest pictures.
+ const FORMAT_NAMES = 'JPEG, PNG, GIF, WebP or AVIF';
+ const sizeText = (b) => formatBytes(b).replace(/\.0 /, ' ');
+ const homeFaq = () => [
+ { q: 'Is OpenVibe.Pics free?', a: `Yes, and there are no ads. 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: '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) => {
r.get('/', async (req, res) => {
const mine = signedIn(req) ? (await store.imagesOf(s, whoOf(req).requester, { limit: 12 })).rows : [];
const mine = signedIn(req) ? (await store.imagesOf(s, whoOf(req).requester, { limit: 12 })).rows : [];
const canUpload = signedIn(req) || cfg.anonymous;
const canUpload = signedIn(req) || cfg.anonymous;
+ const description = 'Upload a picture and get a link to share or embed anywhere. Albums, any size or format, no ads, with location and camera details removed.';
+ const faq = homeFaq();
return page(req, res, {
return page(req, res, {
index: true,
index: true,
- description: 'Upload a picture and get a link to share or embed anywhere. Albums, no ads, open source, with location and camera details removed from every picture.',
- // What the site is, for search engines (as the Download home says it).
+ title: `${SITE_NAME}: share pictures with a link, no ads`,
+ description,
jsonLd: [
jsonLd: [
- seo.jsonLd.website({ name: SITE_NAME, url: base, description: 'Upload a picture and get a link to share or embed anywhere. Albums, no ads, open source, with location and camera details removed from every picture.' }),
- seo.jsonLd.webPage({ name: SITE_NAME, url: `${base}/`, description: 'Upload a picture and get a link to share or embed anywhere. Albums, no ads, open source, with location and camera details removed from every picture.', siteUrl: base }),
+ seo.jsonLd.website({ name: SITE_NAME, url: base, description }),
+ 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.webPage({ name: SITE_NAME, url: `${base}/`, description, siteUrl: base }),
+ seo.jsonLd.faq(faq),
],
],
scripts: canUpload ? ['js/pics.js'] : [],
scripts: canUpload ? ['js/pics.js'] : [],
body: html`<header class="ovp-hero"><p class="ovp-eyebrow">${SITE_NAME}</p>
body: html`<header class="ovp-hero"><p class="ovp-eyebrow">${SITE_NAME}</p>
@@ -216,14 +233,46 @@ function createPicsRoutes(ctx) {
@@ -216,14 +233,46 @@ function createPicsRoutes(ctx) {
<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 ads, no feed, 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 ads, no feed, 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>`}
${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>` : ''}
${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>` : ''}
+<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">
<section class="ovp-section ovp-features" aria-label="What you get">
<div class="ovp-feature"><h3>Links that work everywhere</h3><p>A page to share, a direct link for <code><img></code>, and Markdown, BBCode and HTML ready to copy.</p></div>
<div class="ovp-feature"><h3>Links that work everywhere</h3><p>A page to share, a direct link for <code><img></code>, and Markdown, BBCode and HTML ready to copy.</p></div>
<div class="ovp-feature"><h3>Unlisted by default</h3><p>Only people with the link see a picture. Nothing is listed, ranked or indexed.</p></div>
<div class="ovp-feature"><h3>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>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>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 320 to 2560 pixels wide, as WebP, AVIF, JPEG or PNG, each with a link of its own.</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>
<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>
+<section class="ovp-section" aria-labelledby="h-links"><h2 id="h-links">The links a picture gets</h2>
+<p class="ovp-section-lede">Each picture has a short name of eight letters and digits. These are the links it answers at:</p>
+<dl class="ovp-links">
+<div><dt>Its page</dt><dd><code>${base}/i/<name></code></dd></div>
+<div><dt>The picture itself, in the format you uploaded</dt><dd><code>${base}/i/<name>.jpg</code></dd></div>
+<div><dt>Another width or format</dt><dd><code>${base}/i/<name>/w/640.avif</code></dd></div>
+<div><dt>Markdown, with your alt text</dt><dd><code></code></dd></div>
+</dl></section>
+<section class="ovp-section" aria-labelledby="h-api"><h2 id="h-api">Upload from your own app</h2>
+<p class="ovp-section-lede">The API takes the picture as the request body, with an OpenVibe sign-in token, and answers with its links.</p>
+<pre class="ovp-code" tabindex="0" aria-label="Upload with curl"><code>curl -X POST ${base}/api/v1/images \\
+ -H "Authorization: Bearer $OPENVIBE_TOKEN" \\
+ -H "Content-Type: image/jpeg" \\
+ --data-binary @photo.jpg</code></pre></section>
+<section class="ovp-section" aria-labelledby="h-limits"><h2 id="h-limits">What a free account gets</h2>
+<div class="ovp-table-wrap" tabindex="0" role="region" aria-labelledby="h-limits"><table class="ovp-size-table ovp-limits"><thead><tr><th scope="col">Limit</th><th scope="col">Signed in</th>${cfg.anonymous ? html`<th scope="col">Without an account</th>` : ''}</tr></thead><tbody>
+<tr><th scope="row">Largest picture</th><td>${sizeText(cfg.maxImageBytes)}</td>${cfg.anonymous ? html`<td>${sizeText(cfg.anonymousMaxImageBytes)}</td>` : ''}</tr>
+<tr><th scope="row">Pictures a day</th><td>${cfg.imagesPerDay}</td>${cfg.anonymous ? html`<td>${cfg.anonymousPerDay}</td>` : ''}</tr>
+<tr><th scope="row">Storage</th><td>${sizeText(cfg.quotaBytes)}</td>${cfg.anonymous ? html`<td>Kept ${cfg.anonymousIdleDays} days after the last view</td>` : ''}</tr>
+</tbody></table></div></section>
+<section class="ovp-section" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2><div class="faq">${faq.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</div></section>
+<section class="ovp-section" aria-labelledby="h-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>`,
});
});
});
});
@@ -524,7 +573,10 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
@@ -524,7 +573,10 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
// ── Discovery, legal, update log ────────────────────────
// ── Discovery, legal, update log ────────────────────────
r.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
r.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
- .send(`# openvibe.pics: pictures are unlisted; only the home page is for search engines.\n${seo.robotsTxt({ disallow: ['/i/', '/a/', '/mine', '/albums', '/staff', '/api/', '/auth/', '/upload'] })}`));
+ .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.
+ 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}` })))));
r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, no ads.',
summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, no ads.',
@@ -25,6 +25,9 @@ const TAGLINE = 'Your files, shared on your terms.';
@@ -25,6 +25,9 @@ const TAGLINE = 'Your files, shared on your terms.';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// The product's own navigation: one entry per public page it serves.
// The product's own navigation: one entry per public page it serves.
const NAV = [
const NAV = [
+ { label: 'Your files', href: '/files' },
+ { label: 'Backups', href: '/sync' },
+ { label: 'Safety', href: '/safety' },
{ label: 'What shipped', href: '/updates' },
{ label: 'What shipped', href: '/updates' },
];
];
@@ -38,11 +38,18 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
@@ -38,11 +38,18 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const head = r.text.slice(0, r.text.indexOf('</head>'));
const head = r.text.slice(0, r.text.indexOf('</head>'));
assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
- assert.ok(head.includes('<title>OpenVibe.Download: Your files, shared on your terms.</title>'), 'home title');
+ assert.ok(head.includes('<title>OpenVibe.Download: a free 25 GB drive with share links</title>'), `home title: ${(/<title>([^<]*)/.exec(head) || [])[1]}`);
+ assert.match(head, /<meta name="description" content="A private drive with 25 GB free: upload files up to 2 GB, share them with links you can take back, and back up a folder from your computer\.">/, 'a description that says what it does, with the real numbers');
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.download/">'), 'canonical');
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.download/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
+ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'FAQPage'], 'the home JSON-LD');
+ // FAQPage only for questions shown on the page: every question in it is a <summary> in the body.
+ const faq = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).find((x) => x['@type'] === 'FAQPage');
+ const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));
+ assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'the questions on the page');
+ assert.ok(r.text.includes('openvibe-download sync ~/Documents --to Backups/laptop'), 'the backup command, as the Backups page gives it');
+ assert.ok(!r.text.includes('2.0 GB'), 'sizes as people write them');
// openvibe-shared/app-icon slugs the site id to lower-case letters (media-hub → mediahub) in data-ov-icon.
// openvibe-shared/app-icon slugs the site id to lower-case letters (media-hub → mediahub) in data-ov-icon.
for (const tag of ['<meta property="og:title"', 'data-ov-icon="mediahub"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
for (const tag of ['<meta property="og:title"', 'data-ov-icon="mediahub"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
assert.ok(head.includes(tag), `head has ${tag}`);
@@ -62,7 +69,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
@@ -62,7 +69,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.strictEqual(r.status, 200, r.text.slice(0, 300));
assert.match(r.headers.get('content-type'), /^text\/html/);
assert.match(r.headers.get('content-type'), /^text\/html/);
assert.ok(r.text.includes('OpenVibe.Download'), 'the site name is on the page');
assert.ok(r.text.includes('OpenVibe.Download'), 'the site name is on the page');
- assert.ok(r.text.includes('Your files, shared on your terms.'), 'the tagline is on the page');
+ assert.ok(r.text.includes('A private drive with 25 GB free.'), 'the lede says what a free account gets');
const h1 = (/<h1[^>]*>([\s\S]*?)<\/h1>/.exec(r.text) || [])[1] || '';
const h1 = (/<h1[^>]*>([\s\S]*?)<\/h1>/.exec(r.text) || [])[1] || '';
assert.strictEqual(h1.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(), 'Your files, shared on your terms.', 'the heading is one sentence');
assert.strictEqual(h1.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(), 'Your files, shared on your terms.', 'the heading is one sentence');
assert.ok(r.text.includes('/shared/showcase.css'), 'the showcase stylesheet');
assert.ok(r.text.includes('/shared/showcase.css'), 'the showcase stylesheet');
@@ -14,8 +14,9 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -14,8 +14,9 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// headroom; file counts are the measured counts and external files stay at 0.
// headroom; file counts are the measured counts and external files stay at 0.
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 29.7, // measured 27.5 (real glyphs for the db, link and shield icons, shared 2.21.1); was 27.5
- htmlBrotliKB: 7.7, // 7.1 (real glyphs for the db, link and shield icons, shared 2.21.1); was 6.9
+ htmlRawKB: 43.5, // measured 39.4 on 10-22: the home gained the backup command, the limits table, six questions (and their
+ // FAQPage JSON-LD) and next steps on the network; was 29.7 at 27.5
+ htmlBrotliKB: 10.4, // 9.4 on 10-22 (the same sections); was 7.7 at 7.1
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
jsBrotliKB: 62.5, // 56.5
@@ -76,6 +76,24 @@ function multipart(fields, file) {
@@ -76,6 +76,24 @@ function multipart(fields, file) {
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/'), true, 'pictures are not crawled');
});
});
+ await check('the home: how it works, the links, the API, the limits from config, questions that match its FAQPage', async () => {
+ const home = (await t.get('/', { headers: PICS })).text;
+ assert.strictEqual((home.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
+ assert.match(home, /<title>OpenVibe\.Pics: share pictures with a link, no ads<\/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');
+ assert.ok(home.includes('<td>20 MB</td>') && home.includes('<td>200</td>') && home.includes('<td>5 GB</td>'), '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');
+ const ld = [...home.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1])));
+ const faq = ld.find((x) => x['@type'] === 'FAQPage');
+ const shown = [...home.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));
+ assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the FAQPage is on the page');
+ const map = (await t.get('/sitemap.xml', { headers: PICS })).text;
+ assert.ok(map.includes('<loc>https://openvibe.pics/</loc>') && map.includes('<loc>https://openvibe.pics/dmca</loc>'), 'the sitemap lists the home and the rules');
+ assert.ok(!map.includes('/i/'), 'and never a picture');
+ assert.ok((await t.get('/robots.txt', { headers: PICS })).text.includes('Sitemap: https://openvibe.pics/sitemap.xml'), 'robots.txt names it');
+ });
+
await check('signed out, uploads are refused until MEDIAHUB_PICS_ANONYMOUS is on', async () => {
await check('signed out, uploads are refused until MEDIAHUB_PICS_ANONYMOUS is on', async () => {
const r = await t.get('/api/v1/images', { method: 'POST', body: jpegWithSecrets, headers: { ...SAME, 'content-type': 'image/jpeg' } });
const r = await t.get('/api/v1/images', { method: 'POST', body: jpegWithSecrets, headers: { ...SAME, 'content-type': 'image/jpeg' } });
assert.strictEqual(r.status, 401);
assert.strictEqual(r.status, 401);