openvibers/openvibe.design Commits
Icons: draw one without signing in, inside caps (#83)
Changes
@@@ -61,3 +61,11 @@ OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230# DESIGN_EVENTS_SECRET=# DESIGN_EVENTS_URL=# DESIGN_EVENTS_ENDPOINT=++++# Drawing an icon without signing in (plan L50 step 5): caps a day per guest, per address and for all guests.++DESIGN_GLYPH_GUEST_PER_DAY=2++DESIGN_GLYPH_GUEST_PER_ADDRESS=4++DESIGN_GLYPH_GUEST_DAILY=25++# Cloudflare Turnstile on the guest form: on only when both are set (the site key is public, the secret is not).++DESIGN_TURNSTILE_SITE_KEY=++DESIGN_TURNSTILE_SECRET=nnew file mode 100644@@@ -0,0 +1,14 @@++-- phase: expand++-- Drawing an icon without signing in (plan L50 step 5; the owner, 11-01: "without login, but prevent abuse"). Each++-- guest attempt is one row: the guest (a hash of their browser's random id, never the id itself) and a salted hash of++-- the address that day (never the address). The caps count these rows: per guest a day, per address a day, and all++-- guests together a day, so a guest's cost is bounded whatever they try. Rows older than two days are pruned.++CREATE TABLE IF NOT EXISTS glyph_guest_draws (++ id bigserial PRIMARY KEY,++ guest text COLLATE "C" NOT NULL,++ ip_hash text COLLATE "C" NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now()++);++CREATE INDEX IF NOT EXISTS glyph_guest_draws_guest ON glyph_guest_draws (guest, created_at DESC);++CREATE INDEX IF NOT EXISTS glyph_guest_draws_ip ON glyph_guest_draws (ip_hash, created_at DESC);++CREATE INDEX IF NOT EXISTS glyph_guest_draws_at ON glyph_guest_draws (created_at DESC);@@@ -88,6 +88,8 @@ async function createApp(opts = {}) {const app = express();app.disable('x-powered-by');++ // Cloudflare Turnstile on the guest icon form (L50 step 5), only when both of its keys are set.++ const turnstileSrc = config.glyphGuests && config.glyphGuests.turnstileSiteKey && config.glyphGuests.turnstileSecret ? ['https://challenges.cloudflare.com'] : [];app.set('trust proxy', config.trustProxy);app.locals.ctx = ctx;@@@ -106,7 +108,7 @@ async function createApp(opts = {}) {directives: {defaultSrc: ["'self'"],// The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.-- scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', ...turnstileSrc],styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],// Theme previews may carry inline data: images and any https image the catalogue references.@@@ -114,7 +116,7 @@ async function createApp(opts = {}) {// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],// 'self' covers the Arena's about:srcdoc iframes; generated pages cannot navigate the top window.-- frameSrc: ["'self'", 'https://openvibe.network'],++ frameSrc: ["'self'", 'https://openvibe.network', ...turnstileSrc],frameAncestors: ["'none'"],objectSrc: ["'none'"],baseUri: ["'self'"],@@@ -73,6 +73,16 @@ function load(env = process.env) {scope: env.DESIGN_BENCH_SCOPE || 'bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.entry.create bench.board.read',},++ // Drawing an icon without signing in (plan L50 step 5): caps per guest, per address and for all guests a day,++ // and Cloudflare Turnstile when both of its keys are set (the guest form then carries its widget).++ glyphGuests: {++ perGuest: Math.max(0, int(env.DESIGN_GLYPH_GUEST_PER_DAY, 2)),++ perAddress: Math.max(0, int(env.DESIGN_GLYPH_GUEST_PER_ADDRESS, 4)),++ daily: Math.max(0, int(env.DESIGN_GLYPH_GUEST_DAILY, 25)),++ turnstileSiteKey: env.DESIGN_TURNSTILE_SITE_KEY || '',++ turnstileSecret: env.DESIGN_TURNSTILE_SECRET || '',++ },++// Design Studio part B: a small per-design AI edit budget (click edits and undo do not spend AI).studio: {chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),@@@ -5,7 +5,9 @@* each one. Approved drafts are what openvibe-shared's scripts/pull-glyph-drafts.js takes into the registry; nothing* reaches a page without that review and a Shared release.*-- * draft({ subject, name, sentence }) → { kept: [draft…], refused: [why…] } (at most PER_DAY a person per day)++ * draft({ subject, name, sentence, guest }) → { kept: [draft…], refused: [why…] } (at most PER_DAY a person per day)++ * guest: { id, ipHash } for someone not signed in (L50 step 5): subject is then guest:<id>, and the guest caps++ * (per guest, per address, all guests, a day; config.glyphGuests) are checked and the attempt recorded first.* list({ status, subject, limit }) → drafts, newest first* review({ id, action: 'approve' | 'reject', reviewer })*/@@@ -34,18 +36,36 @@ reads at 16 px, in the hand of a clean line icon set. A dot is a zero-length lindifferent idea, not the same drawing moved. Answer with JSON only:{"candidates": ["<path d=\\"…\\"/>", …]} with ${CANDIDATES} different candidates.${examples()}`;--function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {++function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests = { perGuest: 2, perAddress: 4, daily: 25 } }) {const id = () => `gd_${crypto.randomBytes(8).toString('hex')}`;const iso = () => new Date(clock.now()).toISOString();-- async function draft({ subject, name, sentence = '' }) {-- if (!subject) throw Object.assign(new Error('sign in to draw an icon'), { status: 401 });++ /** A guest's three caps, then their attempt recorded (before any model is asked, so a failure counts too). */++ async function admitGuest(guest) {++ const count = async (where, args) => Number(((await db.maybe(`SELECT count(*)::int AS n FROM glyph_guest_draws WHERE ${where} AND created_at > now() - interval '1 day'`, args)) || {}).n || 0);++ const busy = (msg) => Object.assign(new Error(msg), { status: 429 });++ if (!guests.daily || await count('true', []) >= guests.daily) throw busy('Guests have drawn all the icons they can today. Sign in to draw more, or come back tomorrow.');++ if (!guests.perAddress || await count('ip_hash = $1', [guest.ipHash]) >= guests.perAddress) throw busy('That is all the icons this connection can draw today without signing in. Sign in to draw more.');++ if (!guests.perGuest || await count('guest = $1', [guest.id]) >= guests.perGuest) throw busy(`Without signing in you can draw ${guests.perGuest} icon${guests.perGuest === 1 ? '' : 's'} a day. Sign in to draw more.`);++ await db.exec('INSERT INTO glyph_guest_draws (guest, ip_hash) VALUES ($1, $2)', [guest.id, guest.ipHash]);++ // Two days is enough to count a day; older rows go.++ db.exec("DELETE FROM glyph_guest_draws WHERE created_at < now() - interval '2 days'").catch(() => {});++ }++++ async function draft({ subject, name, sentence = '', guest = null }) {++ if (!subject && !guest) throw Object.assign(new Error('sign in to draw an icon'), { status: 401 });++ if (guest && !/^[0-9a-f]{16,64}$/.test(String(guest.id || ''))) throw Object.assign(new Error('this browser cannot draw without signing in'), { status: 400 });++ const who = subject || `guest:${guest.id}`;const n = String(name || '').trim().toLowerCase();if (!NAME_RE.test(n)) throw Object.assign(new Error('a name is 2 to 32 lowercase letters, digits and dashes, like "hot-tub"'), { status: 422 });const what = String(sentence || '').trim().slice(0, 200);-- const today = await db.maybe("SELECT count(*)::int AS n FROM glyph_drafts WHERE subject = $1 AND created_at > now() - interval '1 day'", [subject]);-- if ((today && today.n) >= PER_DAY) throw Object.assign(new Error(`${PER_DAY} drafts a day: more tomorrow`), { status: 429 });if (!ai || !ai.enabled) throw Object.assign(new Error('drawing needs OpenVibe.AI, which is not configured here'), { status: 503 });++ if (subject) {++ const today = await db.maybe("SELECT count(*)::int AS n FROM glyph_drafts WHERE subject = $1 AND created_at > now() - interval '1 day'", [subject]);++ if ((today && today.n) >= PER_DAY) throw Object.assign(new Error(`${PER_DAY} drafts a day: more tomorrow`), { status: 429 });++ } else {++ await admitGuest(guest);++ }const ask = async (extra = '') => {const answer = await ai.text({system: SYSTEM,@@@ -53,7 +73,8 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {maxTokens: 1800,target: { service: 'design', type: 'glyph_draft', id: n },prefer: { provider: 'anthropic', model: 'claude-opus-5-5' }, // Claude first, soft, on the person's key (L37)-- person: subject,++ // A person's own key when they have one (L37); a guest runs on OpenVibe's, inside the caps above.++ ...(subject ? { person: subject } : {}),});let got = [];try {@@@ -78,7 +99,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {if (!r.ok) { refused.push(r.why); continue; }if (seen.has(r.svg)) continue;seen.add(r.svg);-- const row = { id: id(), subject, name: n, sentence: what, svg: r.svg, provider: answer.provider, model: answer.model, status: 'draft', created_at: iso() };++ const row = { id: id(), subject: who, name: n, sentence: what, svg: r.svg, provider: answer.provider, model: answer.model, status: 'draft', created_at: iso() };await db.exec('INSERT INTO glyph_drafts (id, subject, name, sentence, svg, provider, model, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',[row.id, row.subject, row.name, row.sentence, row.svg, row.provider, row.model, row.status, row.created_at]);kept.push(row);@@@ -17,7 +17,8 @@* it with its OpenVibe twin (L50 step 4); POST /api/v1/glyphs/migrate as data* GET /icons/kit, POST /icons/kit pick glyphs, get a font with only those (WOFF2, TTF, CSS, code points) as a ZIP* GET /icons/kit.zip?names=&name=&fa= the same kit from a link-- * POST /icons/draft signed in: OpenVibe.AI drafts candidates (same-origin form)++ * POST /icons/draft OpenVibe.AI drafts candidates (same-origin form); signed out too, inside the++ * guest caps (L50 step 5), behind Cloudflare Turnstile when it is configured* POST /icons/drafts/:id/review an admin approves or rejects one (same-origin form)* GET /api/v1/glyphs the registry as data; ?names=a,b,fa-c (up to 120) for just those, with solids* GET /api/v1/glyphs/drafts approved drafts, for openvibe-shared to take in (an admin may ask for others)@@@ -27,7 +28,8 @@ const { http } = require('openvibe-contracts');const glyphs = require('openvibe-shared/glyphs');const { asyncRouter } = require('./router');const { send, baseUrl } = require('../render/layout');--const { writeAllowed } = require('./same-origin');++const { writeAllowed, sameOrigin } = require('./same-origin');++const nodeCrypto = require('crypto');const { iconsPage, glyphsJson } = require('../views/icons');const { glyphPage, faAlternativePage, kitPage, migratePage } = require('../views/icon-page');const aliases = require('../glyphs/aliases');@@@ -45,7 +47,39 @@ const OPEN = { 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policyfunction createIconRoutes(ctx) {const { config, s, ai, log = console } = ctx;-- const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai });++ const guestCaps = config.glyphGuests || { perGuest: 2, perAddress: 4, daily: 25 };++ const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai, guests: guestCaps });++ // A guest (L50 step 5): a random id in an httpOnly cookie, stored only as a keyed hash; the address only as a hash++ // salted with the day. The key is the service's own secret (a new one each start where none is set: development).++ const GUEST_COOKIE = 'ovd_guest';++ const guestKey = (config.oauth && config.oauth.clientSecret) ? `glyph-guest:${config.oauth.clientSecret}` : nodeCrypto.randomBytes(32).toString('hex');++ const keyed = (v) => nodeCrypto.createHmac('sha256', guestKey).update(String(v)).digest('hex').slice(0, 40);++ const guestOf = (req, res, { make = false } = {}) => {++ let raw = String((req.cookies && req.cookies[GUEST_COOKIE]) || '');++ if (!/^[0-9a-f]{32}$/.test(raw)) {++ if (!make) return null;++ raw = nodeCrypto.randomBytes(16).toString('hex');++ res.cookie(GUEST_COOKIE, raw, { httpOnly: true, sameSite: 'lax', secure: /^https:/.test(String(config.baseUrl || '')), maxAge: 365 * 86400 * 1000, path: '/icons' });++ }++ const day = new Date().toISOString().slice(0, 10);++ return { id: keyed(raw), ipHash: keyed(`${day}|${req.ip || ''}`) };++ };++ // Cloudflare Turnstile, when both keys are set: the guest form carries its widget and the token is checked here.++ const turnstileOn = !!(guestCaps.turnstileSiteKey && guestCaps.turnstileSecret);++ async function turnstileOk(req) {++ if (!turnstileOn) return true;++ const token = String((req.body || {})['cf-turnstile-response'] || '');++ if (!token || token.length > 2048) return false;++ try {++ const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {++ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ secret: guestCaps.turnstileSecret, response: token, ...(req.ip ? { remoteip: req.ip } : {}) }).toString(),++ signal: AbortSignal.timeout(5000),++ });++ const j = await r.json().catch(() => ({}));++ return j.success === true;++ } catch { return false; }++ }const r = asyncRouter();const form = express.urlencoded({ extended: false, limit: '8kb' });const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);@@@ -56,13 +90,14 @@ function createIconRoutes(ctx) {r.get('/icons', async (req, res) => {const subject = subjectOf(req);const reviewer = isAdmin(req);++ const guest = subject ? null : guestOf(req, res);const [mine, waiting] = await Promise.all([-- subject ? drafts.list({ subject, limit: 24 }).catch(() => []) : [],++ subject ? drafts.list({ subject, limit: 24 }).catch(() => []) : guest ? drafts.list({ subject: `guest:${guest.id}`, limit: 24 }).catch(() => []) : [],reviewer ? drafts.list({ status: 'draft', limit: 60 }).catch(() => []) : [],]);const notice = req.query.drafted != null ? (Number(req.query.drafted) ? `${Number(req.query.drafted)} candidate${Number(req.query.drafted) === 1 ? '' : 's'} drawn: they wait for review below.` : 'No candidate passed the checks this time: try saying more about it.') : req.query.reviewed ? `Draft ${String(req.query.reviewed).slice(0, 20)}.` : '';const draw = /^[a-z][a-z0-9-]{1,31}$/.test(String(req.query.draw || '')) ? String(req.query.draw) : '';-- send(res, 200, iconsPage({ base: baseUrl(), mine, drafts: waiting, reviewer, notice, error: String(req.query.error || '').slice(0, 200), draw }), { viewer: req.viewer });++ send(res, 200, iconsPage({ base: baseUrl(), mine, drafts: waiting, reviewer, notice, error: String(req.query.error || '').slice(0, 200), draw, guest: !subject ? { perDay: guestCaps.perGuest, turnstile: turnstileOn ? guestCaps.turnstileSiteKey : '' } : null }), { viewer: req.viewer, ...(guest ? { cacheControl: 'private, no-store' } : {}) });});r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));@@@ -144,9 +179,16 @@ function createIconRoutes(ctx) {r.post('/icons/draft', form, async (req, res) => {if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');const subject = subjectOf(req);-- if (!subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/icons#draw')}`);++ let guest = null;++ if (!subject) {++ // A guest's post must come from this site (a cross-site form could spend the guests' day), and pass++ // Turnstile when it is on.++ if (!sameOrigin(req, config)) return res.status(403).send('Open this form on openvibe.design.');++ if (!await turnstileOk(req)) return back(res, { error: 'The check that you are a person did not pass: try again.', draw: String((req.body || {}).name || '') });++ guest = guestOf(req, res, { make: true });++ }try {-- const out = await drafts.draft({ subject, name: (req.body || {}).name, sentence: (req.body || {}).sentence });++ const out = await drafts.draft({ subject, guest, name: (req.body || {}).name, sentence: (req.body || {}).sentence });back(res, { drafted: String(out.kept.length), draw: String((req.body || {}).name || '') });} catch (err) {if (!err.status || err.status >= 500) log.warn('[OpenVibe.Design] glyph draft failed:', err && err.message);@@@ -38,7 +38,7 @@ function stillFontAwesome() {const inner = (name) => glyphs.path(name);const svg = (d, size = 24) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${d}</svg>`;--function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], reviewer = false, notice = '', error = '', draw = '' } = {}) {++function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], reviewer = false, notice = '', error = '', draw = '', guest = null } = {}) {const fa = faByGlyph();const left = stillFontAwesome();const covered = usage.names.filter((n) => glyphs.fromFa(n.name)).reduce((a, n) => a + n.uses, 0);@@@ -97,9 +97,11 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r<form class="ic-form" method="post" action="/icons/draft"><label><span>Name</span><input name="name" required pattern="[a-z][a-z0-9-]{1,31}" maxlength="32" placeholder="hot-tub" value="${esc(draw)}" autocomplete="off" spellcheck="false"></label><label class="ic-grow"><span>What it stands for</span><input name="sentence" maxlength="200" placeholder="A tub with steam over it: a hot tub at a rental"></label>++ ${guest && guest.turnstile ? `<div class="cf-turnstile ic-human" data-sitekey="${esc(guest.turnstile)}" data-theme="auto" data-size="flexible"></div><script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>` : ''}<button type="submit" class="ic-btn is-go">${kit.icon('spark')} Draw it</button></form>-- ${mine.length ? `<h3>Your drafts</h3><ul class="ic-drafts">${mine.map((d) => draftCard(d)).join('')}</ul>` : ''}++ ${guest ? `<p class="ic-more">No account needed: you can draw ${guest.perDay} icon${guest.perDay === 1 ? '' : 's'} a day as a guest. <a href="/auth/login?next=${encodeURIComponent('/icons#draw')}">Sign in</a> to draw more and see your drafts on any device.</p>` : ''}++ ${mine.length ? `<h3>${guest ? 'Your drafts, on this browser' : 'Your drafts'}</h3><ul class="ic-drafts">${mine.map((d) => draftCard(d)).join('')}</ul>` : ''}${reviewer ? `<h3>Waiting for review</h3>${drafts.length ? `<ul class="ic-drafts">${drafts.map((d) => draftCard(d, reviewForm(d))).join('')}</ul>` : '<p class="ic-more">Nothing waiting.</p>'}` : ''}</section>`;@@@ -88,13 +88,51 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header]) assert.equal(clean(bad).ok, false, what);});-- await check('drawing needs a signed-in person and this site\'s own form', async () => {-- const anon = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'hot-tub' }), origin: ORIGIN });-- assert.equal(anon.status, 303);-- assert.match(anon.headers.get('location'), /^\/auth\/login\?next=/);++ await check('drawing takes this site\'s own form, signed in or not', async () => {const cross = await t.get('/icons/draft', { method: 'POST', as: ana, ...form({ name: 'hot-tub' }), origin: 'https://evil.test' });assert.equal(cross.status, 403);-- assert.equal(asked.length, 0, 'no AI call for either');++ const anonCross = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'hot-tub' }), origin: 'https://evil.test' });++ assert.equal(anonCross.status, 403, 'a guest\'s post must come from openvibe.design too');++ const noOrigin = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'hot-tub' }) });++ assert.equal(noOrigin.status, 403, 'and say so');++ assert.equal(asked.length, 0, 'no AI call for any of them');++ });++++ await check('a guest draws without signing in, inside the caps: per guest, per address, all guests', async () => {++ answer = JSON.stringify({ candidates: ['<rect x="4" y="10" width="16" height="8" rx="2"/><line x1="8" y1="4" x2="8" y2="7"/>', '<path d="M5 10h14v8H5z"/><path d="M9 6c0-1 1-1 1-2"/>'] });++ const before = asked.length;++ const page0 = await t.get('/icons');++ assert.ok(page0.text.includes('you can draw 2 icons a day as a guest'), 'the form says what a guest may do');++ const first = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'kettle', sentence: 'A kettle' }), origin: ORIGIN });++ assert.equal(first.status, 303);++ assert.match(first.headers.get('location'), /^\/icons\?drafted=2&draw=kettle#draw$/);++ const cookie = (first.headers.get('set-cookie') || '').split(';')[0];++ assert.match(cookie, /^ovd_guest=[0-9a-f]{32}$/, 'a random guest id, in a cookie');++ assert.match(first.headers.get('set-cookie'), /HttpOnly/i);++ assert.equal(asked.length, before + 1);++ assert.equal(asked[asked.length - 1].person, undefined, 'a guest runs on OpenVibe\'s key, not a person\'s');++ const row = await t.store.db.maybe("SELECT subject FROM glyph_drafts WHERE name = 'kettle'");++ assert.match(row.subject, /^guest:[0-9a-f]{40}$/, 'stored under a keyed hash, never the id itself');++ const mine = await t.get('/icons', { headers: { cookie } });++ assert.ok(mine.text.includes('<h3>Your drafts, on this browser</h3>'), 'the guest sees their drafts');++ assert.match(mine.headers.get('cache-control'), /no-store/);++ const said = (r) => decodeURIComponent(String(r.headers.get('location') || '').replace(/\+/g, ' '));++ const asGuest = (f) => { const x = form(f); return { ...x, headers: { ...x.headers, cookie } }; };++ const second = await t.get('/icons/draft', { method: 'POST', ...asGuest({ name: 'teapot' }), origin: ORIGIN });++ assert.match(second.headers.get('location'), /drafted=2/);++ const third = await t.get('/icons/draft', { method: 'POST', ...asGuest({ name: 'mugs' }), origin: ORIGIN });++ assert.match(said(third), /you can draw 2 icons a day/, 'the guest\'s day is spent');++ assert.equal(asked.length, before + 2, 'and no model was asked for the third');++ // Two drawn so far from this address; two more from fresh browsers reach its four, the fifth is refused.++ const fresh = () => t.get('/icons/draft', { method: 'POST', ...form({ name: 'cups' }), origin: ORIGIN });++ assert.match(said(await fresh()), /drafted=2/);++ assert.match(said(await fresh()), /drafted=2/);++ assert.match(said(await fresh()), /this connection can draw today/, 'a new cookie does not reset the address');++ const rows = await t.store.db.many('SELECT guest, ip_hash FROM glyph_guest_draws');++ assert.ok(rows.length >= 4 && rows.every((r) => /^[0-9a-f]{40}$/.test(r.guest) && /^[0-9a-f]{40}$/.test(r.ip_hash)), 'only hashes are kept');++ await t.store.db.exec('DELETE FROM glyph_guest_draws');++ await t.store.db.exec("DELETE FROM glyph_drafts WHERE subject LIKE 'guest:%'");++ asked.length = 0;});await check('OpenVibe.AI drafts candidates; the checked ones are kept, the rest refused', async () => {
@@ -61,3 +61,11 @@ OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230
@@ -61,3 +61,11 @@ OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230
# DESIGN_EVENTS_SECRET=
# DESIGN_EVENTS_SECRET=
# DESIGN_EVENTS_URL=
# DESIGN_EVENTS_URL=
# DESIGN_EVENTS_ENDPOINT=
# DESIGN_EVENTS_ENDPOINT=
+
+# Drawing an icon without signing in (plan L50 step 5): caps a day per guest, per address and for all guests.
+DESIGN_GLYPH_GUEST_PER_DAY=2
+DESIGN_GLYPH_GUEST_PER_ADDRESS=4
+DESIGN_GLYPH_GUEST_DAILY=25
+# Cloudflare Turnstile on the guest form: on only when both are set (the site key is public, the secret is not).
+DESIGN_TURNSTILE_SITE_KEY=
+DESIGN_TURNSTILE_SECRET=
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+-- phase: expand
+-- Drawing an icon without signing in (plan L50 step 5; the owner, 11-01: "without login, but prevent abuse"). Each
+-- guest attempt is one row: the guest (a hash of their browser's random id, never the id itself) and a salted hash of
+-- the address that day (never the address). The caps count these rows: per guest a day, per address a day, and all
+-- guests together a day, so a guest's cost is bounded whatever they try. Rows older than two days are pruned.
+CREATE TABLE IF NOT EXISTS glyph_guest_draws (
+ id bigserial PRIMARY KEY,
+ guest text COLLATE "C" NOT NULL,
+ ip_hash text COLLATE "C" NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS glyph_guest_draws_guest ON glyph_guest_draws (guest, created_at DESC);
+CREATE INDEX IF NOT EXISTS glyph_guest_draws_ip ON glyph_guest_draws (ip_hash, created_at DESC);
+CREATE INDEX IF NOT EXISTS glyph_guest_draws_at ON glyph_guest_draws (created_at DESC);
@@ -88,6 +88,8 @@ async function createApp(opts = {}) {
@@ -88,6 +88,8 @@ async function createApp(opts = {}) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
+ // Cloudflare Turnstile on the guest icon form (L50 step 5), only when both of its keys are set.
+ const turnstileSrc = config.glyphGuests && config.glyphGuests.turnstileSiteKey && config.glyphGuests.turnstileSecret ? ['https://challenges.cloudflare.com'] : [];
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
app.locals.ctx = ctx;
app.locals.ctx = ctx;
@@ -106,7 +108,7 @@ async function createApp(opts = {}) {
@@ -106,7 +108,7 @@ async function createApp(opts = {}) {
directives: {
directives: {
defaultSrc: ["'self'"],
defaultSrc: ["'self'"],
// The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.
// The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.
- scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
+ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', ...turnstileSrc],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
// Theme previews may carry inline data: images and any https image the catalogue references.
// Theme previews may carry inline data: images and any https image the catalogue references.
@@ -114,7 +116,7 @@ async function createApp(opts = {}) {
@@ -114,7 +116,7 @@ async function createApp(opts = {}) {
// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.
// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
// 'self' covers the Arena's about:srcdoc iframes; generated pages cannot navigate the top window.
// 'self' covers the Arena's about:srcdoc iframes; generated pages cannot navigate the top window.
- frameSrc: ["'self'", 'https://openvibe.network'],
+ frameSrc: ["'self'", 'https://openvibe.network', ...turnstileSrc],
frameAncestors: ["'none'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
baseUri: ["'self'"],
@@ -73,6 +73,16 @@ function load(env = process.env) {
@@ -73,6 +73,16 @@ function load(env = process.env) {
scope: env.DESIGN_BENCH_SCOPE || 'bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.entry.create bench.board.read',
scope: env.DESIGN_BENCH_SCOPE || 'bench.task.create bench.round.create bench.round.read bench.judgement.cast bench.entry.create bench.board.read',
},
},
+ // Drawing an icon without signing in (plan L50 step 5): caps per guest, per address and for all guests a day,
+ // and Cloudflare Turnstile when both of its keys are set (the guest form then carries its widget).
+ glyphGuests: {
+ perGuest: Math.max(0, int(env.DESIGN_GLYPH_GUEST_PER_DAY, 2)),
+ perAddress: Math.max(0, int(env.DESIGN_GLYPH_GUEST_PER_ADDRESS, 4)),
+ daily: Math.max(0, int(env.DESIGN_GLYPH_GUEST_DAILY, 25)),
+ turnstileSiteKey: env.DESIGN_TURNSTILE_SITE_KEY || '',
+ turnstileSecret: env.DESIGN_TURNSTILE_SECRET || '',
+ },
+
// Design Studio part B: a small per-design AI edit budget (click edits and undo do not spend AI).
// Design Studio part B: a small per-design AI edit budget (click edits and undo do not spend AI).
studio: {
studio: {
chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),
chatEditsPerDesign: Math.max(1, int(env.DESIGN_CHAT_EDITS_PER_DESIGN, 5)),
@@ -5,7 +5,9 @@
@@ -5,7 +5,9 @@
* each one. Approved drafts are what openvibe-shared's scripts/pull-glyph-drafts.js takes into the registry; nothing
* each one. Approved drafts are what openvibe-shared's scripts/pull-glyph-drafts.js takes into the registry; nothing
* reaches a page without that review and a Shared release.
* reaches a page without that review and a Shared release.
*
*
- * draft({ subject, name, sentence }) → { kept: [draft…], refused: [why…] } (at most PER_DAY a person per day)
+ * draft({ subject, name, sentence, guest }) → { kept: [draft…], refused: [why…] } (at most PER_DAY a person per day)
+ * guest: { id, ipHash } for someone not signed in (L50 step 5): subject is then guest:<id>, and the guest caps
+ * (per guest, per address, all guests, a day; config.glyphGuests) are checked and the attempt recorded first.
* list({ status, subject, limit }) → drafts, newest first
* list({ status, subject, limit }) → drafts, newest first
* review({ id, action: 'approve' | 'reject', reviewer })
* review({ id, action: 'approve' | 'reject', reviewer })
*/
*/
@@ -34,18 +36,36 @@ reads at 16 px, in the hand of a clean line icon set. A dot is a zero-length lin
@@ -34,18 +36,36 @@ reads at 16 px, in the hand of a clean line icon set. A dot is a zero-length lin
different idea, not the same drawing moved. Answer with JSON only:
different idea, not the same drawing moved. Answer with JSON only:
{"candidates": ["<path d=\\"…\\"/>", …]} with ${CANDIDATES} different candidates.${examples()}`;
{"candidates": ["<path d=\\"…\\"/>", …]} with ${CANDIDATES} different candidates.${examples()}`;
-function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
+function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests = { perGuest: 2, perAddress: 4, daily: 25 } }) {
const id = () => `gd_${crypto.randomBytes(8).toString('hex')}`;
const id = () => `gd_${crypto.randomBytes(8).toString('hex')}`;
const iso = () => new Date(clock.now()).toISOString();
const iso = () => new Date(clock.now()).toISOString();
- async function draft({ subject, name, sentence = '' }) {
- if (!subject) throw Object.assign(new Error('sign in to draw an icon'), { status: 401 });
+ /** A guest's three caps, then their attempt recorded (before any model is asked, so a failure counts too). */
+ async function admitGuest(guest) {
+ const count = async (where, args) => Number(((await db.maybe(`SELECT count(*)::int AS n FROM glyph_guest_draws WHERE ${where} AND created_at > now() - interval '1 day'`, args)) || {}).n || 0);
+ const busy = (msg) => Object.assign(new Error(msg), { status: 429 });
+ if (!guests.daily || await count('true', []) >= guests.daily) throw busy('Guests have drawn all the icons they can today. Sign in to draw more, or come back tomorrow.');
+ if (!guests.perAddress || await count('ip_hash = $1', [guest.ipHash]) >= guests.perAddress) throw busy('That is all the icons this connection can draw today without signing in. Sign in to draw more.');
+ if (!guests.perGuest || await count('guest = $1', [guest.id]) >= guests.perGuest) throw busy(`Without signing in you can draw ${guests.perGuest} icon${guests.perGuest === 1 ? '' : 's'} a day. Sign in to draw more.`);
+ await db.exec('INSERT INTO glyph_guest_draws (guest, ip_hash) VALUES ($1, $2)', [guest.id, guest.ipHash]);
+ // Two days is enough to count a day; older rows go.
+ db.exec("DELETE FROM glyph_guest_draws WHERE created_at < now() - interval '2 days'").catch(() => {});
+ }
+
+ async function draft({ subject, name, sentence = '', guest = null }) {
+ if (!subject && !guest) throw Object.assign(new Error('sign in to draw an icon'), { status: 401 });
+ if (guest && !/^[0-9a-f]{16,64}$/.test(String(guest.id || ''))) throw Object.assign(new Error('this browser cannot draw without signing in'), { status: 400 });
+ const who = subject || `guest:${guest.id}`;
const n = String(name || '').trim().toLowerCase();
const n = String(name || '').trim().toLowerCase();
if (!NAME_RE.test(n)) throw Object.assign(new Error('a name is 2 to 32 lowercase letters, digits and dashes, like "hot-tub"'), { status: 422 });
if (!NAME_RE.test(n)) throw Object.assign(new Error('a name is 2 to 32 lowercase letters, digits and dashes, like "hot-tub"'), { status: 422 });
const what = String(sentence || '').trim().slice(0, 200);
const what = String(sentence || '').trim().slice(0, 200);
- const today = await db.maybe("SELECT count(*)::int AS n FROM glyph_drafts WHERE subject = $1 AND created_at > now() - interval '1 day'", [subject]);
- if ((today && today.n) >= PER_DAY) throw Object.assign(new Error(`${PER_DAY} drafts a day: more tomorrow`), { status: 429 });
if (!ai || !ai.enabled) throw Object.assign(new Error('drawing needs OpenVibe.AI, which is not configured here'), { status: 503 });
if (!ai || !ai.enabled) throw Object.assign(new Error('drawing needs OpenVibe.AI, which is not configured here'), { status: 503 });
+ if (subject) {
+ const today = await db.maybe("SELECT count(*)::int AS n FROM glyph_drafts WHERE subject = $1 AND created_at > now() - interval '1 day'", [subject]);
+ if ((today && today.n) >= PER_DAY) throw Object.assign(new Error(`${PER_DAY} drafts a day: more tomorrow`), { status: 429 });
+ } else {
+ await admitGuest(guest);
+ }
const ask = async (extra = '') => {
const ask = async (extra = '') => {
const answer = await ai.text({
const answer = await ai.text({
system: SYSTEM,
system: SYSTEM,
@@ -53,7 +73,8 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
@@ -53,7 +73,8 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
maxTokens: 1800,
maxTokens: 1800,
target: { service: 'design', type: 'glyph_draft', id: n },
target: { service: 'design', type: 'glyph_draft', id: n },
prefer: { provider: 'anthropic', model: 'claude-opus-5-5' }, // Claude first, soft, on the person's key (L37)
prefer: { provider: 'anthropic', model: 'claude-opus-5-5' }, // Claude first, soft, on the person's key (L37)
- person: subject,
+ // A person's own key when they have one (L37); a guest runs on OpenVibe's, inside the caps above.
+ ...(subject ? { person: subject } : {}),
});
});
let got = [];
let got = [];
try {
try {
@@ -78,7 +99,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
@@ -78,7 +99,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
if (!r.ok) { refused.push(r.why); continue; }
if (!r.ok) { refused.push(r.why); continue; }
if (seen.has(r.svg)) continue;
if (seen.has(r.svg)) continue;
seen.add(r.svg);
seen.add(r.svg);
- const row = { id: id(), subject, name: n, sentence: what, svg: r.svg, provider: answer.provider, model: answer.model, status: 'draft', created_at: iso() };
+ const row = { id: id(), subject: who, name: n, sentence: what, svg: r.svg, provider: answer.provider, model: answer.model, status: 'draft', created_at: iso() };
await db.exec('INSERT INTO glyph_drafts (id, subject, name, sentence, svg, provider, model, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
await db.exec('INSERT INTO glyph_drafts (id, subject, name, sentence, svg, provider, model, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
[row.id, row.subject, row.name, row.sentence, row.svg, row.provider, row.model, row.status, row.created_at]);
[row.id, row.subject, row.name, row.sentence, row.svg, row.provider, row.model, row.status, row.created_at]);
kept.push(row);
kept.push(row);
@@ -17,7 +17,8 @@
@@ -17,7 +17,8 @@
* it with its OpenVibe twin (L50 step 4); POST /api/v1/glyphs/migrate as data
* it with its OpenVibe twin (L50 step 4); POST /api/v1/glyphs/migrate as data
* GET /icons/kit, POST /icons/kit pick glyphs, get a font with only those (WOFF2, TTF, CSS, code points) as a ZIP
* GET /icons/kit, POST /icons/kit pick glyphs, get a font with only those (WOFF2, TTF, CSS, code points) as a ZIP
* GET /icons/kit.zip?names=&name=&fa= the same kit from a link
* GET /icons/kit.zip?names=&name=&fa= the same kit from a link
- * POST /icons/draft signed in: OpenVibe.AI drafts candidates (same-origin form)
+ * POST /icons/draft OpenVibe.AI drafts candidates (same-origin form); signed out too, inside the
+ * guest caps (L50 step 5), behind Cloudflare Turnstile when it is configured
* POST /icons/drafts/:id/review an admin approves or rejects one (same-origin form)
* POST /icons/drafts/:id/review an admin approves or rejects one (same-origin form)
* GET /api/v1/glyphs the registry as data; ?names=a,b,fa-c (up to 120) for just those, with solids
* GET /api/v1/glyphs the registry as data; ?names=a,b,fa-c (up to 120) for just those, with solids
* GET /api/v1/glyphs/drafts approved drafts, for openvibe-shared to take in (an admin may ask for others)
* GET /api/v1/glyphs/drafts approved drafts, for openvibe-shared to take in (an admin may ask for others)
@@ -27,7 +28,8 @@ const { http } = require('openvibe-contracts');
@@ -27,7 +28,8 @@ const { http } = require('openvibe-contracts');
const glyphs = require('openvibe-shared/glyphs');
const glyphs = require('openvibe-shared/glyphs');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { send, baseUrl } = require('../render/layout');
const { send, baseUrl } = require('../render/layout');
-const { writeAllowed } = require('./same-origin');
+const { writeAllowed, sameOrigin } = require('./same-origin');
+const nodeCrypto = require('crypto');
const { iconsPage, glyphsJson } = require('../views/icons');
const { iconsPage, glyphsJson } = require('../views/icons');
const { glyphPage, faAlternativePage, kitPage, migratePage } = require('../views/icon-page');
const { glyphPage, faAlternativePage, kitPage, migratePage } = require('../views/icon-page');
const aliases = require('../glyphs/aliases');
const aliases = require('../glyphs/aliases');
@@ -45,7 +47,39 @@ const OPEN = { 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy
@@ -45,7 +47,39 @@ const OPEN = { 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy
function createIconRoutes(ctx) {
function createIconRoutes(ctx) {
const { config, s, ai, log = console } = ctx;
const { config, s, ai, log = console } = ctx;
- const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai });
+ const guestCaps = config.glyphGuests || { perGuest: 2, perAddress: 4, daily: 25 };
+ const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai, guests: guestCaps });
+ // A guest (L50 step 5): a random id in an httpOnly cookie, stored only as a keyed hash; the address only as a hash
+ // salted with the day. The key is the service's own secret (a new one each start where none is set: development).
+ const GUEST_COOKIE = 'ovd_guest';
+ const guestKey = (config.oauth && config.oauth.clientSecret) ? `glyph-guest:${config.oauth.clientSecret}` : nodeCrypto.randomBytes(32).toString('hex');
+ const keyed = (v) => nodeCrypto.createHmac('sha256', guestKey).update(String(v)).digest('hex').slice(0, 40);
+ const guestOf = (req, res, { make = false } = {}) => {
+ let raw = String((req.cookies && req.cookies[GUEST_COOKIE]) || '');
+ if (!/^[0-9a-f]{32}$/.test(raw)) {
+ if (!make) return null;
+ raw = nodeCrypto.randomBytes(16).toString('hex');
+ res.cookie(GUEST_COOKIE, raw, { httpOnly: true, sameSite: 'lax', secure: /^https:/.test(String(config.baseUrl || '')), maxAge: 365 * 86400 * 1000, path: '/icons' });
+ }
+ const day = new Date().toISOString().slice(0, 10);
+ return { id: keyed(raw), ipHash: keyed(`${day}|${req.ip || ''}`) };
+ };
+ // Cloudflare Turnstile, when both keys are set: the guest form carries its widget and the token is checked here.
+ const turnstileOn = !!(guestCaps.turnstileSiteKey && guestCaps.turnstileSecret);
+ async function turnstileOk(req) {
+ if (!turnstileOn) return true;
+ const token = String((req.body || {})['cf-turnstile-response'] || '');
+ if (!token || token.length > 2048) return false;
+ try {
+ const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ secret: guestCaps.turnstileSecret, response: token, ...(req.ip ? { remoteip: req.ip } : {}) }).toString(),
+ signal: AbortSignal.timeout(5000),
+ });
+ const j = await r.json().catch(() => ({}));
+ return j.success === true;
+ } catch { return false; }
+ }
const r = asyncRouter();
const r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '8kb' });
const form = express.urlencoded({ extended: false, limit: '8kb' });
const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);
const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);
@@ -56,13 +90,14 @@ function createIconRoutes(ctx) {
@@ -56,13 +90,14 @@ function createIconRoutes(ctx) {
r.get('/icons', async (req, res) => {
r.get('/icons', async (req, res) => {
const subject = subjectOf(req);
const subject = subjectOf(req);
const reviewer = isAdmin(req);
const reviewer = isAdmin(req);
+ const guest = subject ? null : guestOf(req, res);
const [mine, waiting] = await Promise.all([
const [mine, waiting] = await Promise.all([
- subject ? drafts.list({ subject, limit: 24 }).catch(() => []) : [],
+ subject ? drafts.list({ subject, limit: 24 }).catch(() => []) : guest ? drafts.list({ subject: `guest:${guest.id}`, limit: 24 }).catch(() => []) : [],
reviewer ? drafts.list({ status: 'draft', limit: 60 }).catch(() => []) : [],
reviewer ? drafts.list({ status: 'draft', limit: 60 }).catch(() => []) : [],
]);
]);
const notice = req.query.drafted != null ? (Number(req.query.drafted) ? `${Number(req.query.drafted)} candidate${Number(req.query.drafted) === 1 ? '' : 's'} drawn: they wait for review below.` : 'No candidate passed the checks this time: try saying more about it.') : req.query.reviewed ? `Draft ${String(req.query.reviewed).slice(0, 20)}.` : '';
const notice = req.query.drafted != null ? (Number(req.query.drafted) ? `${Number(req.query.drafted)} candidate${Number(req.query.drafted) === 1 ? '' : 's'} drawn: they wait for review below.` : 'No candidate passed the checks this time: try saying more about it.') : req.query.reviewed ? `Draft ${String(req.query.reviewed).slice(0, 20)}.` : '';
const draw = /^[a-z][a-z0-9-]{1,31}$/.test(String(req.query.draw || '')) ? String(req.query.draw) : '';
const draw = /^[a-z][a-z0-9-]{1,31}$/.test(String(req.query.draw || '')) ? String(req.query.draw) : '';
- send(res, 200, iconsPage({ base: baseUrl(), mine, drafts: waiting, reviewer, notice, error: String(req.query.error || '').slice(0, 200), draw }), { viewer: req.viewer });
+ send(res, 200, iconsPage({ base: baseUrl(), mine, drafts: waiting, reviewer, notice, error: String(req.query.error || '').slice(0, 200), draw, guest: !subject ? { perDay: guestCaps.perGuest, turnstile: turnstileOn ? guestCaps.turnstileSiteKey : '' } : null }), { viewer: req.viewer, ...(guest ? { cacheControl: 'private, no-store' } : {}) });
});
});
r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));
r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));
@@ -144,9 +179,16 @@ function createIconRoutes(ctx) {
@@ -144,9 +179,16 @@ function createIconRoutes(ctx) {
r.post('/icons/draft', form, async (req, res) => {
r.post('/icons/draft', form, async (req, res) => {
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
const subject = subjectOf(req);
const subject = subjectOf(req);
- if (!subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/icons#draw')}`);
+ let guest = null;
+ if (!subject) {
+ // A guest's post must come from this site (a cross-site form could spend the guests' day), and pass
+ // Turnstile when it is on.
+ if (!sameOrigin(req, config)) return res.status(403).send('Open this form on openvibe.design.');
+ if (!await turnstileOk(req)) return back(res, { error: 'The check that you are a person did not pass: try again.', draw: String((req.body || {}).name || '') });
+ guest = guestOf(req, res, { make: true });
+ }
try {
try {
- const out = await drafts.draft({ subject, name: (req.body || {}).name, sentence: (req.body || {}).sentence });
+ const out = await drafts.draft({ subject, guest, name: (req.body || {}).name, sentence: (req.body || {}).sentence });
back(res, { drafted: String(out.kept.length), draw: String((req.body || {}).name || '') });
back(res, { drafted: String(out.kept.length), draw: String((req.body || {}).name || '') });
} catch (err) {
} catch (err) {
if (!err.status || err.status >= 500) log.warn('[OpenVibe.Design] glyph draft failed:', err && err.message);
if (!err.status || err.status >= 500) log.warn('[OpenVibe.Design] glyph draft failed:', err && err.message);
@@ -38,7 +38,7 @@ function stillFontAwesome() {
@@ -38,7 +38,7 @@ function stillFontAwesome() {
const inner = (name) => glyphs.path(name);
const inner = (name) => glyphs.path(name);
const svg = (d, size = 24) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${d}</svg>`;
const svg = (d, size = 24) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${d}</svg>`;
-function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], reviewer = false, notice = '', error = '', draw = '' } = {}) {
+function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], reviewer = false, notice = '', error = '', draw = '', guest = null } = {}) {
const fa = faByGlyph();
const fa = faByGlyph();
const left = stillFontAwesome();
const left = stillFontAwesome();
const covered = usage.names.filter((n) => glyphs.fromFa(n.name)).reduce((a, n) => a + n.uses, 0);
const covered = usage.names.filter((n) => glyphs.fromFa(n.name)).reduce((a, n) => a + n.uses, 0);
@@ -97,9 +97,11 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
@@ -97,9 +97,11 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
<form class="ic-form" method="post" action="/icons/draft">
<form class="ic-form" method="post" action="/icons/draft">
<label><span>Name</span><input name="name" required pattern="[a-z][a-z0-9-]{1,31}" maxlength="32" placeholder="hot-tub" value="${esc(draw)}" autocomplete="off" spellcheck="false"></label>
<label><span>Name</span><input name="name" required pattern="[a-z][a-z0-9-]{1,31}" maxlength="32" placeholder="hot-tub" value="${esc(draw)}" autocomplete="off" spellcheck="false"></label>
<label class="ic-grow"><span>What it stands for</span><input name="sentence" maxlength="200" placeholder="A tub with steam over it: a hot tub at a rental"></label>
<label class="ic-grow"><span>What it stands for</span><input name="sentence" maxlength="200" placeholder="A tub with steam over it: a hot tub at a rental"></label>
+ ${guest && guest.turnstile ? `<div class="cf-turnstile ic-human" data-sitekey="${esc(guest.turnstile)}" data-theme="auto" data-size="flexible"></div><script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>` : ''}
<button type="submit" class="ic-btn is-go">${kit.icon('spark')} Draw it</button>
<button type="submit" class="ic-btn is-go">${kit.icon('spark')} Draw it</button>
</form>
</form>
- ${mine.length ? `<h3>Your drafts</h3><ul class="ic-drafts">${mine.map((d) => draftCard(d)).join('')}</ul>` : ''}
+ ${guest ? `<p class="ic-more">No account needed: you can draw ${guest.perDay} icon${guest.perDay === 1 ? '' : 's'} a day as a guest. <a href="/auth/login?next=${encodeURIComponent('/icons#draw')}">Sign in</a> to draw more and see your drafts on any device.</p>` : ''}
+ ${mine.length ? `<h3>${guest ? 'Your drafts, on this browser' : 'Your drafts'}</h3><ul class="ic-drafts">${mine.map((d) => draftCard(d)).join('')}</ul>` : ''}
${reviewer ? `<h3>Waiting for review</h3>${drafts.length ? `<ul class="ic-drafts">${drafts.map((d) => draftCard(d, reviewForm(d))).join('')}</ul>` : '<p class="ic-more">Nothing waiting.</p>'}` : ''}
${reviewer ? `<h3>Waiting for review</h3>${drafts.length ? `<ul class="ic-drafts">${drafts.map((d) => draftCard(d, reviewForm(d))).join('')}</ul>` : '<p class="ic-more">Nothing waiting.</p>'}` : ''}
</section>`;
</section>`;
@@ -88,13 +88,51 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
@@ -88,13 +88,51 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
]) assert.equal(clean(bad).ok, false, what);
]) assert.equal(clean(bad).ok, false, what);
});
});
- await check('drawing needs a signed-in person and this site\'s own form', async () => {
- const anon = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'hot-tub' }), origin: ORIGIN });
- assert.equal(anon.status, 303);
- assert.match(anon.headers.get('location'), /^\/auth\/login\?next=/);
+ await check('drawing takes this site\'s own form, signed in or not', async () => {
const cross = await t.get('/icons/draft', { method: 'POST', as: ana, ...form({ name: 'hot-tub' }), origin: 'https://evil.test' });
const cross = await t.get('/icons/draft', { method: 'POST', as: ana, ...form({ name: 'hot-tub' }), origin: 'https://evil.test' });
assert.equal(cross.status, 403);
assert.equal(cross.status, 403);
- assert.equal(asked.length, 0, 'no AI call for either');
+ const anonCross = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'hot-tub' }), origin: 'https://evil.test' });
+ assert.equal(anonCross.status, 403, 'a guest\'s post must come from openvibe.design too');
+ const noOrigin = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'hot-tub' }) });
+ assert.equal(noOrigin.status, 403, 'and say so');
+ assert.equal(asked.length, 0, 'no AI call for any of them');
+ });
+
+ await check('a guest draws without signing in, inside the caps: per guest, per address, all guests', async () => {
+ answer = JSON.stringify({ candidates: ['<rect x="4" y="10" width="16" height="8" rx="2"/><line x1="8" y1="4" x2="8" y2="7"/>', '<path d="M5 10h14v8H5z"/><path d="M9 6c0-1 1-1 1-2"/>'] });
+ const before = asked.length;
+ const page0 = await t.get('/icons');
+ assert.ok(page0.text.includes('you can draw 2 icons a day as a guest'), 'the form says what a guest may do');
+ const first = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'kettle', sentence: 'A kettle' }), origin: ORIGIN });
+ assert.equal(first.status, 303);
+ assert.match(first.headers.get('location'), /^\/icons\?drafted=2&draw=kettle#draw$/);
+ const cookie = (first.headers.get('set-cookie') || '').split(';')[0];
+ assert.match(cookie, /^ovd_guest=[0-9a-f]{32}$/, 'a random guest id, in a cookie');
+ assert.match(first.headers.get('set-cookie'), /HttpOnly/i);
+ assert.equal(asked.length, before + 1);
+ assert.equal(asked[asked.length - 1].person, undefined, 'a guest runs on OpenVibe\'s key, not a person\'s');
+ const row = await t.store.db.maybe("SELECT subject FROM glyph_drafts WHERE name = 'kettle'");
+ assert.match(row.subject, /^guest:[0-9a-f]{40}$/, 'stored under a keyed hash, never the id itself');
+ const mine = await t.get('/icons', { headers: { cookie } });
+ assert.ok(mine.text.includes('<h3>Your drafts, on this browser</h3>'), 'the guest sees their drafts');
+ assert.match(mine.headers.get('cache-control'), /no-store/);
+ const said = (r) => decodeURIComponent(String(r.headers.get('location') || '').replace(/\+/g, ' '));
+ const asGuest = (f) => { const x = form(f); return { ...x, headers: { ...x.headers, cookie } }; };
+ const second = await t.get('/icons/draft', { method: 'POST', ...asGuest({ name: 'teapot' }), origin: ORIGIN });
+ assert.match(second.headers.get('location'), /drafted=2/);
+ const third = await t.get('/icons/draft', { method: 'POST', ...asGuest({ name: 'mugs' }), origin: ORIGIN });
+ assert.match(said(third), /you can draw 2 icons a day/, 'the guest\'s day is spent');
+ assert.equal(asked.length, before + 2, 'and no model was asked for the third');
+ // Two drawn so far from this address; two more from fresh browsers reach its four, the fifth is refused.
+ const fresh = () => t.get('/icons/draft', { method: 'POST', ...form({ name: 'cups' }), origin: ORIGIN });
+ assert.match(said(await fresh()), /drafted=2/);
+ assert.match(said(await fresh()), /drafted=2/);
+ assert.match(said(await fresh()), /this connection can draw today/, 'a new cookie does not reset the address');
+ const rows = await t.store.db.many('SELECT guest, ip_hash FROM glyph_guest_draws');
+ assert.ok(rows.length >= 4 && rows.every((r) => /^[0-9a-f]{40}$/.test(r.guest) && /^[0-9a-f]{40}$/.test(r.ip_hash)), 'only hashes are kept');
+ await t.store.db.exec('DELETE FROM glyph_guest_draws');
+ await t.store.db.exec("DELETE FROM glyph_drafts WHERE subject LIKE 'guest:%'");
+ asked.length = 0;
});
});
await check('OpenVibe.AI drafts candidates; the checked ones are kept, the rest refused', async () => {
await check('OpenVibe.AI drafts candidates; the checked ones are kept, the rest refused', async () => {