openvibers/openvibe.tips Commits
Paid sounds: creators upload up to 12 sounds, supporters pay to play one, the overlay plays it and chat names it (kind sound, POST /api/v1/sound-requests) (#61)
Changes
@@@ -1,6 +1,6 @@# OpenVibe.Tips--> Creator support: tips, goals, paid messages, TTS and media requests, overlays.++> Creator support: tips, goals, paid messages, TTS, media requests and paid sounds, overlays.**Status:** alpha — runtime built and tested against stubs (roadmap Wave 9). Deployed internally, notlaunched: running on the host since 2026-09-23 on `127.0.0.1:4610` only, with no@@@ -21,7 +21,7 @@ from *interaction delivered*.## Owns- `tip_interactions`, `creator_tip_profiles`, `tip_goals`, `tip_goal_contributions`, `paid_messages`,-- `paid_media_requests`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`++ `paid_media_requests`, `creator_sounds`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`(its own PostgreSQL database `ov_tips`, schema in [migrations/](migrations/), access in `server/db.js`) —interaction state and **references** to Billing transaction ids, never a mutable cash balance- revocable scoped overlay tokens (`overlay_tokens`, hashed at rest; never creator cookies)@@@ -55,9 +55,9 @@ from *interaction delivered*.## Capabilities--Implemented here (the service manifest's `capabilities`, 18 `tips.*` ids, audience `openvibe.tips`):++Implemented here (the service manifest's `capabilities`, 19 `tips.*` ids, audience `openvibe.tips`):`tips.profile.get|update`, `tips.checkout.create`, `tips.superchat.create`, `tips.tts.request`,--`tips.media_request.create`, `tips.interaction.get|list|record|moderate`, `tips.goal.create|update|close`,++`tips.media_request.create`, `tips.sound.request`, `tips.interaction.get|list|record|moderate`, `tips.goal.create|update|close`,`tips.overlay.token.create|revoke`, `tips.overlay.config.get|update` and `tips.simulation.run`.Called elsewhere, as the service principal `tips`:@@@ -169,6 +169,8 @@ token as a Bearer and act on their own things only. The API never reads cookies.| `POST /paid-messages` | `tips.superchat.create` | the supporter || `POST /tts-requests` `{…, tts: {text, voice}}` | `tips.tts.request` | the supporter || `POST /media-requests` `{…, media: {url}}` | `tips.media_request.create` | the supporter |++| `POST /sound-requests` `{…, sound: {id}}` | `tips.sound.request` | the supporter |++| `GET /profiles/:creator/sounds` | `tips.profile.get` (switched-off pages) | anyone when the page is on; the owner || `GET /interactions/:id` | `tips.interaction.get` | its creator or supporter || `GET /interactions?creator=\|supporter=&cursor=` | `tips.interaction.list` | own receipts; `?as=creator` own tips || `POST /interactions/external` `{creator, provider, provider_ref, amount_cents, supporter_name?, message?, announce?}` | `tips.interaction.record` | — |@@@ -309,6 +311,14 @@ with Tips' service token (audience `openvibe.chat`; OpenVibe.Chat `docs/chat-ing| `tts` | `POST /internal/chat/messages` | a `tts` line (`message` = the filtered TTS text) with `tts { voice, identity_key: tips:<interaction>, key: tips-<interaction> }`: Chat saves it and reads it aloud || `media_request` | none | fails at once: the media queue is not Chat's |++**Paid sounds** (`kind: sound`, `server/domain/sounds.js`): a creator keeps up to 12 sounds (MP3, OGG or WAV of++512 KB at most, recognised by their bytes), uploaded from the dashboard with a plain multipart form++(`server/web/multipart.js`) and served from `/sounds/<id>` as their own audio type (`nosniff`, a sandboxing CSP,++cached for good: a file never changes). With paid sounds on (`sounds_enabled`, minimum `sound_min`, 50 Vibes on a++new page), a supporter picks one on the tip page or through `POST /api/v1/sound-requests`. After settlement the++overlay alert carries the sound and the overlay plays it in place of the alert sound; the chat line reads++"<name> played “<sound>” for N Vibes".++Every body's `key` is the effect's delivery id (`<interaction>:<effect>`; the alert `<delivery id>:alert`):Chat applies a key once and answers a repeat with the first result, so a retry after a 5xx or a lost answernever posts twice. A 4xx (other than 401/408/425/429) fails the effect at once, everything else is retriednnew file mode 100644@@@ -0,0 +1,23 @@++-- phase: expand++-- Paid sounds (server/domain/sounds.js): a creator keeps up to 12 short sounds (MP3, OGG or WAV, 512 KB each),++-- supporters pay to play one, and the creator's overlay plays it on stream. A sound's file never changes, so its++-- URL (/sounds/<id>) is cached for good; deleting one removes the file, and the interactions that played it keep++-- its name in their request.++CREATE TABLE IF NOT EXISTS creator_sounds (++ id text PRIMARY KEY, -- tsnd_<ULID>++ creator_subject text NOT NULL,++ name text NOT NULL,++ mime text NOT NULL CHECK (mime IN ('audio/mpeg', 'audio/ogg', 'audio/wav')),++ bytes integer NOT NULL CHECK (bytes > 0 AND bytes <= 524288),++ sha256 text NOT NULL,++ data bytea NOT NULL,++ created_at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS creator_sounds_creator ON creator_sounds (creator_subject, created_at, id);++++ALTER TABLE creator_tip_profiles ADD COLUMN IF NOT EXISTS sounds_enabled boolean NOT NULL DEFAULT false;++ALTER TABLE creator_tip_profiles ADD COLUMN IF NOT EXISTS sound_min bigint NOT NULL DEFAULT 50;++++ALTER TABLE tip_interactions DROP CONSTRAINT IF EXISTS tip_interactions_kind_check;++ALTER TABLE tip_interactions ADD CONSTRAINT tip_interactions_kind_check++ CHECK (kind IN ('tip', 'paid_message', 'tts', 'media_request', 'sound'));@@@ -6,6 +6,7 @@ html, body { margin: 0; background: transparent; color: #fff; font: 600 28px/1.3#alert img { max-width: 220px; max-height: 160px; display: block; margin: 0 auto 10px; }#alert .title b { color: #60a5fa; }#alert .msg { font-weight: 500; font-size: 24px; margin-top: 8px; overflow-wrap: anywhere; }++#alert .sound { font-weight: 700; font-size: 22px; margin-top: 6px; color: #4ade80; overflow-wrap: anywhere; }#alert .test { font-size: 14px; color: #fbbf24; letter-spacing: .08em; text-transform: uppercase; }#goals { position: fixed; left: 24px; right: 24px; bottom: 24px; display: grid; gap: 10px; }.g { background: rgba(10, 15, 28, .78); border-radius: 12px; padding: 10px 14px; font-size: 20px; }@@@ -59,8 +59,10 @@ dl.receipt dd { margin: 0; overflow-wrap: anywhere; }.creators a { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; background: var(--tips-card); text-decoration: none; color: inherit; }.creators img { border-radius: 50%; }.steps li { margin: 8px 0; }--.js .tip-form .when-tts, .js .tip-form .when-media { display: none; }--.js .tip-form[data-kind=tts] .when-tts, .js .tip-form[data-kind=media_request] .when-media { display: block; }++.js .tip-form .when-tts, .js .tip-form .when-media, .js .tip-form .when-sound { display: none; }++.js .tip-form[data-kind=tts] .when-tts, .js .tip-form[data-kind=media_request] .when-media, .js .tip-form[data-kind=sound] .when-sound { display: block; }++.sounds .sound-pick { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }++.sounds audio { height: 32px; max-width: 100%; }.js .tip-form:not([data-kind=tip]) .when-tip { display: none; }ol.leaderboard { padding-left: 1.6em; }ol.leaderboard li, ul.supporters li { margin: 4px 0; }@@@ -13,7 +13,7 @@var current = null;var retracted = {};var shown = {};-- var HIDDEN = { tip: 'sent a tip', paid_message: 'sent a paid message', tts: 'sent a message to read out', media_request: 'requested media' };++ var HIDDEN = { tip: 'sent a tip', paid_message: 'sent a paid message', tts: 'sent a message to read out', media_request: 'requested media', sound: 'played a sound' };function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }function fmt(n) { try { return Number(n).toLocaleString('en-US'); } catch (e) { return String(n); } }@@@ -51,11 +51,14 @@else title.appendChild(document.createTextNode(p));});box.appendChild(title);++ if (a.sound && a.sound.name) box.appendChild(el('div', 'sound', '\u266a ' + a.sound.name));var text = a.tts ? a.tts.text : a.message;if (s.show_message !== false && text) box.appendChild(el('div', 'msg', text));alertEl.appendChild(box);alertEl.className = 'show';-- if (s.sound_url) { try { new Audio(s.sound_url).play().catch(function () {}); } catch (e) { /* no audio */ } }++ // A paid sound plays instead of the alert sound: it is what the supporter paid for.++ var audio = a.sound && a.sound.url ? a.sound.url : s.sound_url;++ if (audio) { try { new Audio(audio).play().catch(function () {}); } catch (e) { /* no audio */ } }if (s.speak_message && a.tts && window.speechSynthesis) {try { window.speechSynthesis.speak(new SpeechSynthesisUtterance(a.tts.text)); } catch (e) { /* no voice */ }}@@@ -15,6 +15,7 @@* | POST /paid-messages | tips.superchat.create | the supporter |* | POST /tts-requests | tips.tts.request | the supporter |* | POST /media-requests | tips.media_request.create | the supporter |++ * | POST /sound-requests | tips.sound.request | the supporter |* | GET /interactions/:id | tips.interaction.get | its creator or supporter |* | GET /interactions | tips.interaction.list | own receipts; ?as=creator own tips |* | POST /interactions/external | tips.interaction.record | — |@@@ -28,6 +29,7 @@* | POST /overlay-configs, PATCH …/:id | tips.overlay.config.update | owner |* | POST /simulate | tips.simulation.run | owner |* | GET /profiles/:creator/supporters | tips.profile.get (page off) | anyone when the page shows it; owner|++ * | GET /profiles/:creator/sounds | tips.profile.get (page off) | anyone when the page is on; owner |* | GET /me/export, POST /me/erase | — | the supporter's own tips |* | GET /moderation?creator=&state= | tips.interaction.moderate | the creator and their moderators |* | POST /interactions/:id/hide|restore | tips.interaction.moderate | the creator and their moderators |@@@ -50,6 +52,7 @@ const CAP = {superchat: 'tips.superchat.create',tts: 'tips.tts.request',media: 'tips.media_request.create',++ sound: 'tips.sound.request',interactionGet: 'tips.interaction.get',interactionList: 'tips.interaction.list',interactionRecord: 'tips.interaction.record',@@@ -109,6 +112,16 @@ function v1Router({ domain, apiAuth, limits }) {res.json({ profile: profiles.present(p, { owner }), goals: owner ? await goals.presentMany(db, active) : await goals.publicGoals(db, active, p) });}));++ /** The creator's paid sounds (what a supporter may pick for POST /sound-requests), when they are switched on. */++ r.get('/profiles/:creator/sounds', wrap(async (req, res) => {++ const p = await creatorOf(req, req.params.creator);++ const who = req.principal;++ const owner = (who.kind === 'user' && who.subject === p.creator_subject) || (who.kind === 'service' && apiAuth.granted(who, CAP.profileGet));++ if (!p.page_enabled && !owner) fail(404, 'tips.creator_not_found', 'no tip profile for this creator');++ const list = p.sounds_enabled || owner ? await domain.sounds.list(db, p.creator_subject) : [];++ res.json({ creator: { type: 'user', id: p.creator_subject }, enabled: !!p.sounds_enabled, minimum: Number(p.sound_min), sounds: list.map((x) => ({ ...x, url: `${domain.config.baseUrl}${x.url}` })) });++ }));++/** The supporters page: top supporters and recent public messages, as the creator chose to show them. */r.get('/profiles/:creator/supporters', wrap(async (req, res) => {const p = await creatorOf(req, req.params.creator);@@@ -209,6 +222,7 @@ function v1Router({ domain, apiAuth, limits }) {r.post('/paid-messages', pays, ...paid('paid_message', CAP.superchat));r.post('/tts-requests', pays, ...paid('tts', CAP.tts));r.post('/media-requests', pays, ...paid('media_request', CAP.media));++ r.post('/sound-requests', pays, ...paid('sound', CAP.sound));// ── Interactions ─────────────────────────────────────────// The hottest list: a keyset page of any size costs three queries (the page, the creators' filters,@@@ -32,6 +32,8 @@ const TABLES = [{ table: 'tip_interactions', subject: 'creator_subject', file: 'tips-received.json', columns: ['id', 'kind', 'amount', 'currency', 'payment_state', 'settlement', 'origin', 'created_at', 'settled_at', 'reversed_at'], erase: { keep: money } },{ table: 'paid_messages', subject: 'creator_subject', file: null, erase: { keep: money } },{ table: 'paid_media_requests', subject: 'creator_subject', file: null, erase: { keep: money } },++ // The creator's paid sounds: listed in the export (each file is at its /sounds/<id> link), deleted on erasure.++ { table: 'creator_sounds', subject: 'creator_subject', file: 'sounds.json', columns: ['id', 'name', 'mime', 'bytes', 'created_at'] },{ table: 'tip_moderation_log', subject: 'creator_subject', file: null, erase: { keep: 'the creator\'s moderation log, kept for accountability' } },{ table: 'tip_moderation_log', subject: 'actor', file: null, erase: { keep: 'the moderation log names the moderator, for the creator\'s accountability' }, kind: 'tip_moderation_log' },];@@@ -56,6 +56,7 @@ function createEffects(ctx) {if (effect.effect === 'paid_message') job.highlight_seconds = req.highlight_seconds || 0;if (effect.effect === 'tts') job.tts = pv.tts;if (effect.effect === 'media_request') { job.media = req.media; job.text = amount ? `${name} requested media for ${amount}` : `${name} requested media`; }++ if (effect.effect === 'chat_line' && i.kind === 'sound' && req.sound) job.text = `${name} played \u201c${req.sound.name}\u201d${amount ? ` for ${amount}` : ''}${msg}`;return job;}@@@ -33,6 +33,7 @@ const { createOverlays } = require('./overlays');const { createInteractions } = require('./interactions');const { createEffects } = require('./effects');const { createModeration } = require('./moderation');++const { createSounds } = require('./sounds');const MONEY = { isolation: 'serializable' };@@@ -59,6 +60,7 @@ function createDomain({ db, config, outbox, billing, adapters, valkey = null, noctx.outboxKick = () => { try { Promise.resolve(outbox.kick()).catch(() => {}); } catch { /* relay off */ } };ctx.profiles = createProfiles(ctx);++ ctx.sounds = createSounds(ctx);ctx.goals = createGoals(ctx);ctx.overlays = createOverlays(ctx);ctx.interactions = createInteractions(ctx);@@@ -49,7 +49,7 @@ const { parsePrivacy, publicName, shownName, privacyOf, isAnonymous, publicView,const { BillingCallError } = require('../billing-client');const { ENVELOPE } = require('../events/outbox');--const KINDS = ['tip', 'paid_message', 'tts', 'media_request'];++const KINDS = ['tip', 'paid_message', 'tts', 'media_request', 'sound'];// Origins whose chat effects Tips delivers: its own requests, and EXTERNAL tips Billing announced// (their only announcement since the PowerChat webhook moved from Live to Billing).const ANNOUNCED_HERE = ['tips', 'billing-external'];@@@ -103,7 +103,7 @@ function createInteractions(ctx) {/*** Validate a request against the creator's profile. Returns the normalised fields:-- * { kind, amount, message, request: { goal_id, tts, media, highlight_seconds } }.++ * { kind, amount, message, request: { goal_id, tts, media, sound, highlight_seconds } }.*/async function validate(q, profile, input, { simulation = false } = {}) {if (!profile) fail(404, 'tips.creator_not_found', 'this creator has no tip page');@@@ -136,6 +136,14 @@ function createInteractions(ctx) {min(profile.media_request_min, 'a media request');request.media = { ...mediaUrl(input.media && input.media.url), max_seconds: profile.media_max_seconds };}++ if (kind === 'sound') {++ if (!profile.sounds_enabled) fail(409, 'tips.sounds_disabled', `${profile.display_name} has paid sounds switched off`);++ min(Number(profile.sound_min), 'a sound');++ const id = input.sound && typeof input.sound === 'object' ? input.sound.id : input.sound_id;++ const sound = await ctx.sounds.ofCreator(q, profile.creator_subject, id);++ if (!sound) fail(422, 'tips.sound_not_found', `pick one of ${profile.display_name}'s sounds`);++ request.sound = { id: sound.id, name: sound.name };++ }if (input.goal_id) {const g = await ctx.goals.get(q, input.goal_id);if (!g || g.creator_subject !== profile.creator_subject || g.status !== 'active') fail(422, 'tips.goal_not_found', 'that goal is not open on this page');@@@ -333,7 +341,7 @@ function createInteractions(ctx) {// announced (origin billing), never to a real chat room for a simulation.const adapter = test ? 'test' : config.chat.adapter;if (ANNOUNCED_HERE.includes(i.origin) && adapter !== 'none') {-- const chat = { tip: ['chat_line'], paid_message: ['paid_message'], tts: ['chat_line', 'tts'], media_request: ['media_request'] }[i.kind] || [];++ const chat = { tip: ['chat_line'], paid_message: ['paid_message'], tts: ['chat_line', 'tts'], media_request: ['media_request'], sound: ['chat_line'] }[i.kind] || [];for (const effect of chat) effects.push({ effect, adapter, state: 'queued' });}await t.exec(sql`INSERT INTO interaction_effects ${sql.insert(effects.map((e) => ({ interaction_id: i.id, ...e, created_at: at, updated_at: at })))}@@@ -580,7 +588,8 @@ function createInteractions(ctx) {const out = {id: i.id, creator: { type: 'user', id: i.creator_subject }, supporter: i.supporter_subject && named ? { type: 'user', id: i.supporter_subject } : null,supporter_name: named ? (i.supporter_name || null) : publicName(i), kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null,-- tts: req.tts || null, media: req.media ? { url: req.media.url, provider: req.media.provider } : null, goal_id: req.goal_id || null,++ tts: req.tts || null, media: req.media ? { url: req.media.url, provider: req.media.provider } : null,++ sound: req.sound ? { id: req.sound.id, name: req.sound.name, url: `/sounds/${req.sound.id}` } : null, goal_id: req.goal_id || null,funding: i.funding, settlement: i.settlement, origin: i.origin, test: !!i.test,privacy: privacyOf(i), public: publicView(i, { filter }), erased_at: i.erased_at || null,moderation: { state: i.moderation, filtered: !!i.filtered, at: i.moderated_at || null },@@@ -80,7 +80,7 @@ function publicView(i, { at, filter: settings = null } = {}) {test: !!i.test,at: at || i.settled_at || i.created_at,};-- if (isHidden(i)) return { ...base, amount: null, supporter_name: null, message: null, tts: null, media: null, hidden: true };++ if (isHidden(i)) return { ...base, amount: null, supporter_name: null, message: null, tts: null, media: null, sound: null, hidden: true };const message = privateMsg || !i.message ? null : filter.apply(i.message, settings).text;const tts = i.kind === 'tts' && req.tts && !i.erased_at ? { text: filter.apply(req.tts.text, settings, 'speech').text, voice: req.tts.voice } : null;return {@@@ -89,6 +89,8 @@ function publicView(i, { at, filter: settings = null } = {}) {message,tts,media: i.kind === 'media_request' && req.media && !i.erased_at ? { url: req.media.url } : null,++ // A paid sound: the overlay plays it (the creator's own file; nothing the supporter wrote).++ sound: i.kind === 'sound' && req.sound ? { id: req.sound.id, name: req.sound.name, url: `/sounds/${req.sound.id}` } : null,hidden: false,};}@@@ -107,9 +107,9 @@ function createProfiles(ctx) {const LIMITS = {min_amount: [1, 1_000_000], paid_message_min: [1, 1_000_000], tts_min_amount: [1, 1_000_000],-- media_request_min: [1, 1_000_000], media_max_seconds: [10, 3 * 3600],++ media_request_min: [1, 1_000_000], media_max_seconds: [10, 3 * 3600], sound_min: [1, 1_000_000],};-- const BOOLS = ['page_enabled', 'accepting', 'tts_enabled', 'media_requests_enabled'];++ const BOOLS = ['page_enabled', 'accepting', 'tts_enabled', 'media_requests_enabled', 'sounds_enabled'];const on = (v) => v === true || v === 'on' || v === '1' || v === 1;/**@@@ -197,9 +197,14 @@ function createProfiles(ctx) {const out = {creator: { type: 'user', id: p.creator_subject }, handle: p.handle, display_name: p.display_name, avatar_url: p.avatar_url || null,headline: p.headline || null, page_enabled: p.page_enabled, accepting: p.accepting, url: `${ctx.config.baseUrl}/${p.handle}`,-- minimums: { tip: p.min_amount, paid_message: p.paid_message_min, tts: p.tts_enabled ? p.tts_min_amount : null, media_request: p.media_requests_enabled ? p.media_request_min : null },++ minimums: {++ tip: p.min_amount, paid_message: p.paid_message_min, tts: p.tts_enabled ? p.tts_min_amount : null,++ media_request: p.media_requests_enabled ? p.media_request_min : null, sound: p.sounds_enabled ? Number(p.sound_min) : null,++ },tts: { enabled: p.tts_enabled, max_chars: p.tts_max_chars, voice: p.tts_voice },media_requests: { enabled: p.media_requests_enabled, max_seconds: p.media_max_seconds },++ // The sounds themselves: GET /api/v1/profiles/:creator/sounds (and the public page).++ sounds: { enabled: !!p.sounds_enabled },page: p.page,supporters_url: p.page.supporters_page ? `${ctx.config.baseUrl}/${p.handle}/supporters` : null,currency: 'vibes-bits',nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++++/**++ * Paid sounds: a creator's short sounds that supporters pay to play on stream (the owner's "paid soundboard … triggers").++ *++ * list(q, creator) the creator's sounds, oldest first: { id, name, url, mime, bytes }++ * get(q, id) one sound's row with its file (serving)++ * add(creator, { name, data }) a new sound (the creator's profile row is locked: 12 at most)++ * remove(creator, id) delete one of the creator's sounds++ *++ * A file is an MP3, OGG or WAV of at most 512 KB, recognised by its first bytes (never by a name or a header the++ * browser sent), served from /sounds/<id> with its own type, no sniffing and a sandboxing CSP. A sound's file never++ * changes, so the URL is cached for good.++ */++const crypto = require('crypto');++const { sql } = require('openvibe-sdk/db');++const { fail, prefixedId, iso, text } = require('../util');++++const MAX_SOUNDS = 12;++const MAX_BYTES = 512 * 1024;++const ID_RE = /^tsnd_[0-9A-HJKMNP-TV-Z]{26}$/;++++/** The audio type of a file from its first bytes, or null. */++function sniff(buf) {++ if (!Buffer.isBuffer(buf) || buf.length < 12) return null;++ if (buf.slice(0, 3).toString('latin1') === 'ID3') return 'audio/mpeg';++ if (buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0) return 'audio/mpeg'; // an MPEG audio frame sync++ if (buf.slice(0, 4).toString('latin1') === 'OggS') return 'audio/ogg';++ if (buf.slice(0, 4).toString('latin1') === 'RIFF' && buf.slice(8, 12).toString('latin1') === 'WAVE') return 'audio/wav';++ return null;++}++++const EXT = { 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'audio/wav': 'wav' };++++function createSounds(ctx) {++ const { db } = ctx;++ const url = (id) => `/sounds/${id}`;++ const present = (r) => ({ id: r.id, name: r.name, url: url(r.id), mime: r.mime, bytes: Number(r.bytes) });++++ async function list(q, creator) {++ const rows = await q.many(sql`SELECT id, name, mime, bytes FROM creator_sounds WHERE creator_subject = ${String(creator || '')} ORDER BY created_at, id`);++ return rows.map(present);++ }++++ async function get(q, id) {++ if (!ID_RE.test(String(id || ''))) return null;++ return q.maybe(sql`SELECT * FROM creator_sounds WHERE id = ${String(id)}`);++ }++++ /** One of this creator's sounds (for a request), or null. */++ async function ofCreator(q, creator, id) {++ if (!ID_RE.test(String(id || ''))) return null;++ const r = await q.maybe(sql`SELECT id, name, mime, bytes FROM creator_sounds WHERE id = ${String(id)} AND creator_subject = ${String(creator || '')}`);++ return r ? present(r) : null;++ }++++ async function add(creator, { name, data }) {++ const label = text(name, 'name', 40);++ if (!label) fail(422, 'tips.invalid_input', 'a sound needs a name');++ if (!Buffer.isBuffer(data) || !data.length) fail(422, 'tips.invalid_sound', 'no file was sent');++ if (data.length > MAX_BYTES) fail(413, 'tips.sound_too_large', `a sound can be at most ${MAX_BYTES / 1024} KB`);++ const mime = sniff(data);++ if (!mime) fail(415, 'tips.invalid_sound', 'a sound must be an MP3, OGG or WAV file');++ return ctx.tx(async (t) => {++ if (!(await ctx.lockCreator(t, creator))) fail(404, 'tips.profile_not_found', 'set up your tip page first');++ const n = Number((await t.one(sql`SELECT count(*)::int AS n FROM creator_sounds WHERE creator_subject = ${creator}`)).n);++ if (n >= MAX_SOUNDS) fail(409, 'tips.too_many_sounds', `you can keep ${MAX_SOUNDS} sounds; delete one first`);++ const row = await t.one(sql`INSERT INTO creator_sounds (id, creator_subject, name, mime, bytes, sha256, data, created_at)++ VALUES (${prefixedId('tsnd', ctx.now())}, ${creator}, ${label.replace(/[<>]/g, '')}, ${mime}, ${data.length}, ${crypto.createHash('sha256').update(data).digest('hex')}, ${data}, ${iso(ctx.now())})++ RETURNING id, name, mime, bytes`);++ return present(row);++ });++ }++++ async function remove(creator, id) {++ if (!ID_RE.test(String(id || ''))) fail(404, 'tips.sound_not_found', 'no such sound');++ const n = await db.exec(sql`DELETE FROM creator_sounds WHERE id = ${String(id)} AND creator_subject = ${String(creator || '')}`);++ if (!n) fail(404, 'tips.sound_not_found', 'no such sound');++ return true;++ }++++ return { list, get, ofCreator, add, remove, sniff, EXT, MAX_SOUNDS, MAX_BYTES, ID_RE };++}++++module.exports = { createSounds, sniff, MAX_SOUNDS, MAX_BYTES };nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++++/**++ * A small multipart/form-data reader for the one upload form (a creator's sound): text fields and at most one file,++ * from a body already read in full and bounded by the route (express.raw with a limit). Returns null for anything++ * that is not well-formed multipart, so a broken or forged body is refused rather than half-read.++ *++ * parse(buffer, contentType) → { fields: { name: value }, file: { field, filename, type, data } | null } | null++ */++const MAX_PARTS = 16;++++function boundaryOf(contentType) {++ const m = /^multipart\/form-data\s*;(?:.*;)?\s*boundary=(?:"([^"]{1,70})"|([^\s;]{1,70}))/i.exec(String(contentType || ''));++ return m ? (m[1] || m[2]) : null;++}++++function headerParams(value) {++ const out = {};++ const re = /;\s*([a-z*]+)="((?:[^"\\]|\\.)*)"/gi;++ let m;++ while ((m = re.exec(value))) out[m[1].toLowerCase()] = m[2].replace(/\\(.)/g, '$1');++ return out;++}++++function parse(buf, contentType) {++ const boundary = boundaryOf(contentType);++ if (!boundary || !Buffer.isBuffer(buf)) return null;++ const delim = Buffer.from(`--${boundary}`);++ let pos = buf.indexOf(delim);++ if (pos !== 0) return null;++ const fields = {};++ let file = null;++ let parts = 0;++ for (;;) {++ pos += delim.length;++ if (buf.slice(pos, pos + 2).toString('latin1') === '--') return { fields, file }; // the closing delimiter++ if (buf.slice(pos, pos + 2).toString('latin1') !== '\r\n') return null;++ pos += 2;++ if (++parts > MAX_PARTS) return null;++ const headEnd = buf.indexOf('\r\n\r\n', pos, 'latin1');++ if (headEnd < 0) return null;++ const headers = {};++ for (const line of buf.slice(pos, headEnd).toString('utf8').split('\r\n')) {++ const i = line.indexOf(':');++ if (i > 0) headers[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();++ }++ const next = buf.indexOf(Buffer.concat([Buffer.from('\r\n'), delim]), headEnd + 4);++ if (next < 0) return null;++ const body = buf.slice(headEnd + 4, next);++ const disp = headers['content-disposition'] || '';++ if (!/^form-data\b/i.test(disp)) return null;++ const p = headerParams(disp);++ if (!p.name) return null;++ if (p.filename !== undefined) {++ if (file) return null; // one file per upload++ file = { field: p.name, filename: p.filename, type: headers['content-type'] || null, data: Buffer.from(body) };++ } else {++ fields[p.name] = body.toString('utf8');++ }++ pos = next + 2; // past the CRLF, onto the delimiter++ }++}++++module.exports = { parse, boundaryOf };@@@ -11,7 +11,7 @@ const { VOICES } = require('../domain/profiles');const n = (v) => Number(v || 0).toLocaleString('en-US');const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');--const KIND_LABEL = { tip: 'Tip', paid_message: 'Paid message', tts: 'Text-to-speech', media_request: 'Media request' };++const KIND_LABEL = { tip: 'Tip', paid_message: 'Paid message', tts: 'Text-to-speech', media_request: 'Media request', sound: 'Sound' };const PAY_LABEL = { pending: 'Payment pending', settled: 'Paid', reversed: 'Reversed', failed: 'Payment failed' };const DELIVERY_LABEL = { awaiting_payment: 'Waiting for payment', queued: 'Being delivered', delivered: 'Delivered', failed: 'Delivery failed', cancelled: 'Cancelled' };@@@ -38,10 +38,10 @@ function errorBox(msg) { return msg ? `<p class="notice error" role="alert">${es/*** What a new tip page asks for until its creator changes it: migrations/0001_initial.sql's column defaults-- * (creator_tip_profiles.min_amount, paid_message_min, tts_min_amount, media_request_min). test/pages.test.js reads++ * (creator_tip_profiles.min_amount, paid_message_min, tts_min_amount, media_request_min; sound_min in 0005). test/pages.test.js reads* the migration and fails if these drift, so the home page never quotes a number the service does not use.*/--const NEW_PAGE_MINIMUMS = Object.freeze({ tip: 1, paid_message: 100, tts: 100, media_request: 25 });++const NEW_PAGE_MINIMUMS = Object.freeze({ tip: 1, paid_message: 100, tts: 100, media_request: 25, sound: 50 });const vibes = (n) => `${n} ${n === 1 ? 'Vibe' : 'Vibes'}`;/** The home: the product's own words (openvibe-shared/showcase), then the creators who switched their page on. */@@@ -62,12 +62,13 @@ function home({ creators }) {{ icon: 'ov:chat', title: 'A paid message', text: 'Your message, highlighted in their chat.' },{ icon: 'ov:audio', title: 'Text-to-speech', text: 'Your message read out on stream, where the creator turned it on.' },{ icon: 'ov:video', title: 'A media request', text: 'A video for their stream, up to the length they allow, where they turned it on.' },++ { icon: 'ov:audio', title: 'A sound', text: "One of the creator's sounds, played on stream by their overlay, where they turned it on." },],}) + showcase.steps({title: 'How it works',items: [{ title: 'Pick a creator', text: 'Every creator who switched their page on has one at openvibe.tips/name.' },-- { title: 'Choose what to send', text: "A tip, a paid message, text-to-speech or a media request, within the creator's own limits." },++ { title: 'Choose what to send', text: "A tip, a paid message, text-to-speech, a media request or a sound, within the creator's own limits." },{ title: 'Pay with Vibes', text: 'From your Vibes balance, or through checkout. Payments are handled by OpenVibe.Billing; your receipt shows when the payment settled and when your message was delivered.' },],}) + showcase.pricing({@@@ -78,6 +79,7 @@ function home({ creators }) {{ name: 'Paid message', price: vibes(m.paid_message), items: ['Highlighted in chat', 'Shown by their stream overlay'] },{ name: 'Text-to-speech', price: vibes(m.tts), items: ['Read out on stream', 'Only where the creator turned it on'] },{ name: 'Media request', price: vibes(m.media_request), items: ['A video for their stream', 'Only where the creator turned it on'] },++ { name: 'Sound', price: vibes(m.sound), items: ['Played on stream by their overlay', 'Only where the creator turned it on'] },],note: "The creator's page shows their own minimums before you pay.",}) + `@@@ -85,11 +87,13 @@ ${creators.length ? `<section class="sc-sec" id="creators" aria-labelledby="crea}/** The tip form: every field works as a plain POST. */--function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, error }) {++function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, error, sounds = [] }) {const kinds = [['tip', 'Tip', profile.min_amount]];kinds.push(['paid_message', 'Paid message (highlighted in chat)', Math.max(profile.min_amount, profile.paid_message_min)]);if (profile.tts_enabled) kinds.push(['tts', 'Text-to-speech (read out on stream)', Math.max(profile.min_amount, profile.tts_min_amount)]);if (profile.media_requests_enabled) kinds.push(['media_request', 'Media request', Math.max(profile.min_amount, profile.media_request_min)]);++ const soundsOn = profile.sounds_enabled && sounds.length > 0;++ if (soundsOn) kinds.push(['sound', 'A sound (played on stream)', Math.max(profile.min_amount, Number(profile.sound_min))]);const kind = values.kind || 'tip';const active = goals.filter((g) => g.status === 'active');if (!profile.accepting) return `<section class="card"><p>${esc(profile.display_name)} is not accepting tips right now.</p></section>`;@@@ -105,6 +109,9 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e${profile.tts_enabled ? `<div class="when-tts"><label>Text to read out <small>(up to ${profile.tts_max_chars} characters; the message is used when empty)</small><textarea name="tts_text" maxlength="${profile.tts_max_chars}" rows="2">${esc(values.tts_text || '')}</textarea></label><label>Voice <select name="tts_voice">${VOICES.map((v) => `<option${v === (values.tts_voice || profile.tts_voice) ? ' selected' : ''}>${v}</option>`).join('')}</select></label></div>` : ''}${profile.media_requests_enabled ? `<label class="when-media">Media link <small>(YouTube, up to ${Math.floor(profile.media_max_seconds / 60)} minutes)</small><input type="url" name="media_url" value="${esc(values.media_url || '')}" placeholder="https://www.youtube.com/watch?v=…"></label>` : ''}++ ${soundsOn ? `<fieldset class="when-sound sounds"><legend>Which sound</legend>++ ${sounds.map((x, i) => `<label class="sound-pick"><input type="radio" name="sound_id" value="${esc(x.id)}"${(values.sound_id ? values.sound_id === x.id : i === 0) ? ' checked' : ''}> ${esc(x.name)} <audio controls preload="none" src="${esc(x.url)}"></audio></label>`).join('\n ')}++ </fieldset>` : ''}${active.length ? `<label>Count toward <select name="goal_id">${active.length > 1 ? '<option value="">No goal</option>' : ''}${active.map((g) => `<option value="${esc(g.id)}"${values.goal_id === g.id ? ' selected' : ''}>${esc(g.title)}</option>`).join('')}</select></label>` : ''}<fieldset class="pay"><legend>Pay with</legend><label><input type="radio" name="pay_with" value="credit"${values.pay_with !== 'checkout' ? ' checked' : ''}> My Vibes balance</label>@@@ -121,7 +128,7 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e</form>`;}--function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, error, ownerPreview }) {++function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, error, ownerPreview, sounds = [] }) {const active = goals.filter((g) => g.status === 'active');return `${ownerPreview ? '<p class="notice">Your page is switched off: only you can see it, and search engines are told not to index it. Switch it on in the <a href="/dashboard">dashboard</a>.</p>' : ''}<section class="creator-head">@@@ -130,7 +137,7 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er<p class="links"><a href="https://openvibe.live/@${esc(profile.handle)}">Watch on OpenVibe.Live</a> · <a href="/${esc(profile.handle)}/goals">Goals</a>${profile.page.supporters_page ? ` · <a href="/${esc(profile.handle)}/supporters">Supporters</a>` : ''}</p></div></section>${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}--${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error })}`;++${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}`;}function goalsPage({ profile, goals }) {@@@ -214,7 +221,22 @@ ${i.payment.state === 'pending' && i.funding === 'checkout' && !i.checkout?.url<p class="fine">The payment state comes from OpenVibe.Billing. Delivery is tracked separately: a delivery problem never undoes a payment.</p>`;}--function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter, mod = {} }) {++/** The dashboard's paid sounds: the list (with a player and a delete button) and the upload form (a plain multipart POST). */++function soundsCard({ profile: p, sounds, csrf, idem }) {++ const full = sounds.length >= 12;++ return `<section class="card" id="sounds"><h2>Paid sounds</h2>++ <p>Supporters pay to play one of these on stream; your overlay plays it with the alert${p.sounds_enabled ? '' : '. <b>Paid sounds are off</b>: switch them on in Your page above'}. MP3, OGG or WAV, up to 512 KB each, 12 at most.</p>++ ${sounds.length ? `<ul class="sound-list">${sounds.map((x) => `<li><span class="sound-name">${esc(x.name)}</span> <audio controls preload="none" src="${esc(x.url)}"></audio>++ <form method="post" action="/dashboard/sounds/${esc(x.id)}/delete">${hidden({ csrf, idem: `${idem}-sd-${x.id}` })}<button class="btn btn-small" type="submit">Delete</button></form></li>`).join('')}</ul>` : '<p class="muted">No sounds yet.</p>'}++ ${full ? '<p class="muted">You have 12 sounds: delete one to add another.</p>' : `<form method="post" action="/dashboard/sounds" enctype="multipart/form-data" class="sound-upload">${hidden({ csrf, idem: `${idem}-su` })}++ <label>Name <input type="text" name="name" maxlength="40" placeholder="Airhorn"></label>++ <label>File <input type="file" name="file" accept="audio/mpeg,audio/ogg,audio/wav,.mp3,.ogg,.wav" required></label>++ <button class="btn" type="submit">Add sound</button>++ </form>`}++</section>`;++}++++function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter, mod = {}, sounds = [] }) {const p = profile;const f = (k) => (p[k] ? ' checked' : '');const alertCfg = configs.find((c) => c.kind === 'alerts');@@@ -242,6 +264,8 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}<label><input type="checkbox" name="media_requests_enabled" value="1"${f('media_requests_enabled')}> Media requests</label><label>Minimum media request <input type="number" name="media_request_min" min="1" value="${p.media_request_min}"></label><label>Longest media (seconds) <input type="number" name="media_max_seconds" min="10" max="10800" value="${p.media_max_seconds}"></label>++ <label><input type="checkbox" name="sounds_enabled" value="1"${f('sounds_enabled')}> Paid sounds <small>(your sounds, below, played by your overlay)</small></label>++ <label>Minimum for a sound <input type="number" name="sound_min" min="1" value="${p.sound_min}"></label><fieldset><legend>Public pages</legend><label><input type="checkbox" name="page_goal_amounts" value="1"${p.page.goal_amounts ? ' checked' : ''}> Goals show their amounts <small>(off: the percentage only)</small></label><label><input type="checkbox" name="page_goal_supporters" value="1"${p.page.goal_supporters ? ' checked' : ''}> Goals list their latest supporters</label>@@@ -255,6 +279,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}</form></div></section>++${soundsCard({ profile: p, sounds, csrf, idem })}<section class="card"><h2>Goals</h2>${goals.length ? goals.map((g) => `${goalCard(g)}${g.status === 'active' ? `<form class="inline" method="post" action="/dashboard/goals/${esc(g.id)}">${hidden({ csrf, idem: `${idem}-g-${g.id}`, revision: g.revision })}<label>Title <input name="title" maxlength="120" value="${esc(g.title)}"></label><label>Target <input type="number" name="target_amount" min="1" value="${g.target_amount}"></label>@@@ -293,11 +318,12 @@ ${moderationCard({ p, csrf, idem, mod })}<section class="card"><h2>Test your setup</h2><p>Runs the whole path — overlay alert, goal widget, delivery — marked as a simulation. Nothing is charged and nothing is counted. ${chatAdapter === 'none' ? '' : 'Chat delivery of simulations stays inside Tips.'}</p><form method="post" action="/dashboard/simulate">${hidden({ csrf, idem: `${idem}-s` })}-- <label>Kind <select name="kind"><option value="tip">Tip</option><option value="paid_message">Paid message</option>${p.tts_enabled ? '<option value="tts">Text-to-speech</option>' : ''}${p.media_requests_enabled ? '<option value="media_request">Media request</option>' : ''}</select></label>++ <label>Kind <select name="kind"><option value="tip">Tip</option><option value="paid_message">Paid message</option>${p.tts_enabled ? '<option value="tts">Text-to-speech</option>' : ''}${p.media_requests_enabled ? '<option value="media_request">Media request</option>' : ''}${p.sounds_enabled && sounds.length ? '<option value="sound">Sound</option>' : ''}</select></label><label>Amount <input type="number" name="amount" min="1" value="${Math.max(p.min_amount, p.paid_message_min, 100)}"></label><label>Name <input name="supporter_name" value="Test supporter" maxlength="80"></label><label>Message <input name="message" value="This is a test alert" maxlength="300"></label><label class="when-media">Media link (for a media request) <input type="url" name="media_url" placeholder="https://www.youtube.com/watch?v=…"></label>++ ${p.sounds_enabled && sounds.length ? `<label>Sound (for a sound) <select name="sound_id">${sounds.map((x) => `<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></label>` : ''}<button class="btn" type="submit">Send a test</button></form></section>@@@ -35,6 +35,7 @@ const { asyncRouter } = require('./async-router');const pages = require('./pages');const { asset } = require('./layout');const { PAGE_DEFAULTS } = require('../domain/profiles');++const multipart = require('./multipart');// The sitemap's lastmod: when this server booted — a real date, computed once, never per request.const BOOT_AT = new Date().toISOString();@@@ -51,7 +52,7 @@ const RECEIPT_FLASH = new Set(['Your data was erased from your tips']);function createWebRoutes({ domain, config, layout, userAuth }) {// Every handler below may be async: its errors reach the app's error handler (the 500 page).const r = asyncRouter(express.Router());-- const { profiles, goals, interactions, overlays, moderation, db } = domain;++ const { profiles, goals, interactions, overlays, moderation, sounds, db } = domain;const withViewer = viewerMiddleware(userAuth);const form = express.urlencoded({ extended: false, limit: '32kb' });const secret = config.formSecret || crypto.randomBytes(32).toString('hex');@@@ -205,11 +206,12 @@ function createWebRoutes({ domain, config, layout, userAuth }) {configs = await overlays.listConfigs(db, p.creator_subject);}const s = p.creator_subject;-- const [recentRows, goalRows, tokens, totals, deliveries, connected, held, moderators, invites, log, moderating] = await Promise.all([++ const [recentRows, goalRows, tokens, totals, deliveries, connected, held, moderators, invites, log, moderating, soundList] = await Promise.all([interactions.list(db, { creator: s, limit: 20 }).then((out) => interactions.presentMany(db, out.rows, { viewer: 'owner' })),goals.list(db, s).then((list) => goals.presentMany(db, list)),overlays.listTokens(db, s), interactions.totals(db, s), overlays.recentDeliveries(db, s, 20), overlays.connected(s),moderation.heldCount(db, s), moderation.listModerators(db, s), moderation.openInvites(db, s), moderation.log(db, s, 20), moderation.moderatedBy(db, req.viewer.subject),++ sounds.list(db, s),]);html(res, pageFor(req, {title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard',@@@ -219,6 +221,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {deliveries, csrf: csrfFor(req.viewer.subject), idem: idem(), flash, error,connected, chatAdapter: config.chat.adapter,mod: { held, moderators: moderators.map(moderation.presentModerator), invites, log, moderating },++ sounds: soundList,}),}), status);}@@@ -248,6 +251,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {const b = req.body;await profiles.update(p.creator_subject, {page_enabled: bool(b.page_enabled), accepting: bool(b.accepting), tts_enabled: bool(b.tts_enabled), media_requests_enabled: bool(b.media_requests_enabled),++ sounds_enabled: bool(b.sounds_enabled), sound_min: b.sound_min,headline: b.headline, min_amount: b.min_amount, paid_message_min: b.paid_message_min, tts_min_amount: b.tts_min_amount, tts_max_chars: b.tts_max_chars,tts_voice: b.tts_voice, media_request_min: b.media_request_min, media_max_seconds: b.media_max_seconds,page: Object.fromEntries(Object.keys(PAGE_DEFAULTS).map((k) => [k, bool(b[`page_${k}`])])),@@@ -296,10 +300,42 @@ function createWebRoutes({ domain, config, layout, userAuth }) {await interactions.simulate(p, {kind: b.kind, amount: b.amount, message: b.message, supporter_name: b.supporter_name,tts: b.kind === 'tts' ? { text: b.message } : undefined, media: b.kind === 'media_request' ? { url: b.media_url } : undefined,++ sound: b.kind === 'sound' ? { id: b.sound_id } : undefined,}, { by: req.viewer.subject });return { done: 'Test sent — check your overlay' };});++ // ── Paid sounds (domain/sounds.js) ───────────────────────++ // The upload is the one multipart form: read whole (bounded), parsed by ./multipart, refused when malformed.++ const upload = express.raw({ type: 'multipart/form-data', limit: `${Math.ceil(sounds.MAX_BYTES / 1024) + 64}kb` });++ r.post('/dashboard/sounds', withViewer, upload, async (req, res) => {++ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/dashboard')}`);++ const parsed = multipart.parse(req.body, req.get('content-type'));++ req.body = parsed ? parsed.fields : {};++ if (!csrfOk(req)) return await renderDashboard(req, res, { error: 'That form expired. Please try again.', status: 403 });++ try {++ if (!parsed || !parsed.file || !parsed.file.data.length) throw new TipsError(422, 'tips.invalid_sound', 'Choose a sound file to upload.');++ const p = await ensureProfile(req);++ await sounds.add(p.creator_subject, { name: parsed.fields.name || String(parsed.file.filename || '').replace(/\.[a-z0-9]{1,5}$/i, ''), data: parsed.file.data });++ return res.redirect(303, `/dashboard?done=${encodeURIComponent('Sound added')}#sounds`);++ } catch (e) {++ if (e instanceof TipsError) return await renderDashboard(req, res, { error: e.detail || e.message, status: e.status >= 500 ? 500 : 400 });++ throw e;++ }++ });++ action('/dashboard/sounds/:id/delete', async (req, p) => { await sounds.remove(p.creator_subject, req.params.id); return { done: 'Sound deleted' }; });++ // A sound's file never changes (no edit, only delete), so it is cached for good. Its own audio type, no sniffing,++ // and a CSP that runs nothing: it is a file a creator uploaded.++ r.get('/sounds/:id', async (req, res, next) => {++ const row = await sounds.get(db, req.params.id);++ if (!row) return next();++ res.status(200).set({++ 'Content-Type': row.mime, 'Content-Length': String(row.data.length), 'Cache-Control': 'public, max-age=31536000, immutable',++ 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox", 'Cross-Origin-Resource-Policy': 'same-origin',++ 'Content-Disposition': `inline; filename="${row.id}.${sounds.EXT[row.mime] || 'bin'}"`, ETag: `"${row.sha256}"`,++ }).end(row.data);++ });++// ── Overlays (token in the path; no cookies involved) ────const overlayHeaders = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex, nofollow' };async function tokenOr404(req, res) {@@@ -430,7 +466,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow', ogImage: p.avatar_url || undefined, jsonLd,-- body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled }),++ body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled, sounds: p.sounds_enabled ? await sounds.list(db, p.creator_subject) : [] }),}), status, p.page_enabled ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });}r.get('/:handle', withViewer, async (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? await creatorView(req, res) : next()));@@@ -466,7 +502,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/${p.handle}`)}`);const b = req.body || {};const values = {-- kind: b.kind, amount: b.amount, message: b.message, tts_text: b.tts_text, tts_voice: b.tts_voice, media_url: b.media_url, goal_id: b.goal_id, pay_with: b.pay_with,++ kind: b.kind, amount: b.amount, message: b.message, tts_text: b.tts_text, tts_voice: b.tts_voice, media_url: b.media_url, sound_id: b.sound_id, goal_id: b.goal_id, pay_with: b.pay_with,supporter_name: b.supporter_name, anonymous: bool(b.anonymous), hide_amount: bool(b.hide_amount), private_message: bool(b.private_message),};if (!csrfOk(req)) return await creatorView(req, res, { values, error: 'That form expired. Please send it again.', status: 403 });@@@ -478,6 +514,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {kind: b.kind || 'tip', amount: b.amount, message: b.message, goal_id: b.goal_id || undefined,tts: b.kind === 'tts' ? { text: b.tts_text || b.message, voice: b.tts_voice } : undefined,media: b.kind === 'media_request' ? { url: b.media_url } : undefined,++ sound: b.kind === 'sound' ? { id: b.sound_id } : undefined,privacy: { anonymous: values.anonymous, hide_amount: values.hide_amount, private_message: values.private_message },};const funding = b.pay_with === 'checkout' ? 'checkout' : 'credit';@@@ -41,10 +41,11 @@ const { boot, check, done } = require('./helpers/app');});await check('the home quotes the minimums a new page really starts with (the migration defaults)', () => {-- const sql = require('fs').readFileSync(require('path').join(__dirname, '..', 'migrations', '0001_initial.sql'), 'utf8');++ const read = (f) => require('fs').readFileSync(require('path').join(__dirname, '..', 'migrations', f), 'utf8');++ const sql = read('0001_initial.sql') + read('0005_paid_sounds.sql');const def = (col) => Number((sql.match(new RegExp(`\\b${col}\\s+bigint NOT NULL DEFAULT (\\d+)`)) || [])[1]);const { NEW_PAGE_MINIMUMS: m } = require('../server/web/pages');-- assert.deepStrictEqual(m, { tip: def('min_amount'), paid_message: def('paid_message_min'), tts: def('tts_min_amount'), media_request: def('media_request_min') });++ assert.deepStrictEqual(m, { tip: def('min_amount'), paid_message: def('paid_message_min'), tts: def('tts_min_amount'), media_request: def('media_request_min'), sound: def('sound_min') });});await check('every page carries the boost marker and script, and the navbar signs in back to the current page', async () => {nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++// Paid sounds (the owner's "paid soundboard … triggers"): a creator uploads short sounds from the dashboard (a plain++// multipart form, no script needed), switches paid sounds on, a supporter pays to play one, the overlay alert++// carries it and the chat line names it. Files are recognised by their bytes and served as audio that cannot run.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const multipart = require('../server/web/multipart');++++const MP3 = Buffer.concat([Buffer.from('ID3'), Buffer.from([4, 0, 0, 0, 0, 0, 0]), Buffer.alloc(200, 0x55)]);++const OGG = Buffer.concat([Buffer.from('OggS'), Buffer.alloc(200, 0x11)]);++const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x24, 0, 0, 0]), Buffer.from('WAVEfmt '), Buffer.alloc(200, 0)]);++++(async () => {++ await check('multipart: fields and one file; anything malformed is refused, not half-read', async () => {++ const b = 'XyZ123';++ const body = Buffer.concat([++ Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="csrf"\r\n\r\ntok\r\n`),++ Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="file"; filename="horn.mp3"\r\nContent-Type: audio/mpeg\r\n\r\n`), MP3,++ Buffer.from(`\r\n--${b}--\r\n`),++ ]);++ const out = multipart.parse(body, `multipart/form-data; boundary=${b}`);++ assert.strictEqual(out.fields.csrf, 'tok');++ assert.strictEqual(out.file.filename, 'horn.mp3');++ assert.ok(out.file.data.equals(MP3), 'the file bytes, CRLFs inside it untouched');++ assert.strictEqual(multipart.parse(body, 'multipart/form-data; boundary=other'), null);++ assert.strictEqual(multipart.parse(body.slice(0, body.length - 10), `multipart/form-data; boundary=${b}`), null, 'a cut body');++ const two = Buffer.concat([body.slice(0, body.length - `--${b}--\r\n`.length), Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="f2"; filename="x.mp3"\r\n\r\n`), MP3, Buffer.from(`\r\n--${b}--\r\n`)]);++ assert.strictEqual(multipart.parse(two, `multipart/form-data; boundary=${b}`), null, 'one file per upload');++ });++++ const t = await boot();++ const { billing, domain } = t;++ const alex = await t.creator('alex');++ const bob = await t.creator('bob');++ const viewer = t.network.newUser('viewer');++ billing.fund(viewer.subject, 10_000);++ const cookie = (u) => `ov_token=${t.network.signUser(u)}`;++ const get = async (p, u) => { const r = await fetch(t.base + p, { headers: u ? { Cookie: cookie(u) } : {}, redirect: 'manual' }); return { status: r.status, headers: r.headers, text: await r.text(), r }; };++ const field = (html, name) => { const m = html.match(new RegExp(`name="${name}" value="([^"]*)"`)); return m ? m[1] : null; };++ const upload = async (u, { name, file, filename = 'sound.mp3', csrf }) => {++ const fd = new FormData();++ if (csrf !== null) fd.append('csrf', csrf || field((await get('/dashboard', u)).text, 'csrf'));++ if (name != null) fd.append('name', name);++ fd.append('file', new Blob([file]), filename);++ const r = await fetch(`${t.base}/dashboard/sounds`, { method: 'POST', body: fd, redirect: 'manual', headers: { Cookie: cookie(u) } });++ return { status: r.status, location: r.headers.get('location'), text: await r.text() };++ };++ let horn;++++ await check('the dashboard uploads a sound with a plain multipart form; the bytes decide what it is', async () => {++ const page = await get('/dashboard', alex);++ assert.match(page.text, /id="sounds"/);++ assert.match(page.text, /enctype="multipart\/form-data"/);++ const ok = await upload(alex, { name: 'Airhorn', file: MP3 });++ assert.strictEqual(ok.status, 303, ok.text.slice(0, 300));++ assert.match(ok.location, /done=Sound%20added/);++ const fake = await upload(alex, { name: 'Not audio', file: Buffer.from('<script>alert(1)</script> and some more text'), filename: 'evil.mp3' });++ assert.strictEqual(fake.status, 400);++ assert.match(fake.text, /MP3, OGG or WAV/);++ const forged = await upload(alex, { name: 'Forged', file: MP3, csrf: 'nope' });++ assert.strictEqual(forged.status, 403);++ assert.strictEqual((await upload(alex, { file: OGG, filename: 'Rimshot.ogg' })).status, 303, 'the file name names it when the name is empty');++ const list = await domain.sounds.list(domain.db, alex.subject);++ assert.deepStrictEqual(list.map((x) => [x.name, x.mime]), [['Airhorn', 'audio/mpeg'], ['Rimshot', 'audio/ogg']]);++ horn = list[0];++ await domain.sounds.add(bob.subject, { name: 'Bob sound', data: WAV });++ });++++ await check('a sound is served as its own audio type, never sniffed, sandboxed and cached for good', async () => {++ const r = await fetch(`${t.base}${horn.url}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('content-type'), 'audio/mpeg');++ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff');++ assert.match(r.headers.get('content-security-policy'), /default-src 'none'; sandbox/);++ assert.match(r.headers.get('cache-control'), /immutable/);++ assert.ok(Buffer.from(await r.arrayBuffer()).equals(MP3));++ assert.strictEqual((await fetch(`${t.base}/sounds/tsnd_00000000000000000000000000`)).status, 404);++ });++++ await check('switched off, nobody can buy a sound; switched on, the page offers them with a preview', async () => {++ assert.ok(!(await get('/alex', viewer)).text.includes('name="sound_id"'));++ const off = await t.call('POST', '/api/v1/sound-requests', { user: viewer, body: { creator: 'alex', amount: 100, sound: { id: horn.id } } });++ assert.strictEqual(off.status, 409);++ assert.strictEqual(off.json.code, 'tips.sounds_disabled');++ const on = await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { sounds_enabled: true, sound_min: 60 } });++ assert.strictEqual(on.status, 200, on.text);++ assert.deepStrictEqual(on.json.profile.sounds, { enabled: true });++ assert.strictEqual(on.json.profile.minimums.sound, 60);++ const page = await get('/alex', viewer);++ assert.match(page.text, /A sound \(played on stream\)/);++ assert.match(page.text, new RegExp(`name="sound_id" value="${horn.id}" checked`));++ assert.match(page.text, new RegExp(`<audio controls preload="none" src="/sounds/${horn.id}">`));++ const api = await t.call('GET', '/api/v1/profiles/alex/sounds', { token: null });++ assert.strictEqual(api.status, 200);++ assert.deepStrictEqual(api.json.sounds.map((x) => x.url), [`http://tips.test/sounds/${horn.id}`, `http://tips.test${(await domain.sounds.list(domain.db, alex.subject))[1].url}`]);++ assert.strictEqual(api.json.minimum, 60);++ });++++ await check('a supporter pays to play one of the creator\'s sounds: the overlay plays it and chat names it', async () => {++ const low = await t.call('POST', '/api/v1/sound-requests', { user: viewer, body: { creator: 'alex', amount: 10, sound: { id: horn.id } } });++ assert.strictEqual(low.status, 422);++ const foreign = (await domain.sounds.list(domain.db, bob.subject))[0];++ const other = await t.call('POST', '/api/v1/sound-requests', { user: viewer, body: { creator: 'alex', amount: 100, sound: { id: foreign.id } } });++ assert.strictEqual(other.status, 422, 'another creator\'s sound is not on this page');++ const svc = await t.call('POST', '/api/v1/sound-requests', { cap: ['tips.checkout.create'], body: { creator: 'alex', supporter: viewer.subject, amount: 100, sound: { id: horn.id } } });++ assert.strictEqual(svc.status, 403, 'a service needs tips.sound.request');++ const r = await t.call('POST', '/api/v1/sound-requests', { user: viewer, body: { creator: 'alex', amount: 75, sound: { id: horn.id } } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.interaction.kind, 'sound');++ assert.strictEqual(r.json.interaction.payment.state, 'settled');++ assert.deepStrictEqual(r.json.interaction.sound, { id: horn.id, name: 'Airhorn', url: `/sounds/${horn.id}` });++ const alerts = await domain.overlays.recentAlerts(domain.db, alex.subject);++ assert.deepStrictEqual(alerts[0].sound, { id: horn.id, name: 'Airhorn', url: `/sounds/${horn.id}` }, 'the overlay alert carries the sound to play');++ await domain.effects.drain();++ const line = t.adapters.test.jobs.find((j) => j.interaction.id === r.json.interaction.id);++ assert.ok(line, 'a chat line');++ assert.strictEqual(line.effect, 'chat_line');++ assert.strictEqual(line.text, 'Viewer played “Airhorn” for 75 Vibes');++ });++++ await check('the creator tests a sound from the dashboard, deletes one, and keeps 12 at most', async () => {++ const page = await get('/dashboard', alex);++ assert.match(page.text, /<option value="sound">Sound<\/option>/);++ const csrf = field(page.text, 'csrf');++ const del = await fetch(`${t.base}/dashboard/sounds/${horn.id}/delete`, { method: 'POST', redirect: 'manual', headers: { Cookie: cookie(alex), 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ csrf }).toString() });++ assert.strictEqual(del.status, 303);++ assert.strictEqual((await fetch(`${t.base}${horn.url}`)).status, 404);++ const left = (await domain.sounds.list(domain.db, alex.subject)).length;++ for (let i = left; i < 12; i++) await domain.sounds.add(alex.subject, { name: `S${i}`, data: WAV });++ await assert.rejects(domain.sounds.add(alex.subject, { name: 'Thirteenth', data: WAV }), (e) => e.code === 'tips.too_many_sounds');++ await assert.rejects(domain.sounds.add(alex.subject, { name: 'Huge', data: Buffer.concat([MP3, Buffer.alloc(600 * 1024)]) }), (e) => e.code === 'tips.sound_too_large');++ });++++ await done(t);++})();
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# OpenVibe.Tips
# OpenVibe.Tips
-> Creator support: tips, goals, paid messages, TTS and media requests, overlays.
+> Creator support: tips, goals, paid messages, TTS, media requests and paid sounds, overlays.
**Status:** alpha — runtime built and tested against stubs (roadmap Wave 9). Deployed internally, not
**Status:** alpha — runtime built and tested against stubs (roadmap Wave 9). Deployed internally, not
launched: running on the host since 2026-09-23 on `127.0.0.1:4610` only, with no
launched: running on the host since 2026-09-23 on `127.0.0.1:4610` only, with no
@@ -21,7 +21,7 @@ from *interaction delivered*.
@@ -21,7 +21,7 @@ from *interaction delivered*.
## Owns
## Owns
- `tip_interactions`, `creator_tip_profiles`, `tip_goals`, `tip_goal_contributions`, `paid_messages`,
- `tip_interactions`, `creator_tip_profiles`, `tip_goals`, `tip_goal_contributions`, `paid_messages`,
- `paid_media_requests`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`
+ `paid_media_requests`, `creator_sounds`, `overlay_configs`, `overlay_deliveries`, `interaction_effects`
(its own PostgreSQL database `ov_tips`, schema in [migrations/](migrations/), access in `server/db.js`) —
(its own PostgreSQL database `ov_tips`, schema in [migrations/](migrations/), access in `server/db.js`) —
interaction state and **references** to Billing transaction ids, never a mutable cash balance
interaction state and **references** to Billing transaction ids, never a mutable cash balance
- revocable scoped overlay tokens (`overlay_tokens`, hashed at rest; never creator cookies)
- revocable scoped overlay tokens (`overlay_tokens`, hashed at rest; never creator cookies)
@@ -55,9 +55,9 @@ from *interaction delivered*.
@@ -55,9 +55,9 @@ from *interaction delivered*.
## Capabilities
## Capabilities
-Implemented here (the service manifest's `capabilities`, 18 `tips.*` ids, audience `openvibe.tips`):
+Implemented here (the service manifest's `capabilities`, 19 `tips.*` ids, audience `openvibe.tips`):
`tips.profile.get|update`, `tips.checkout.create`, `tips.superchat.create`, `tips.tts.request`,
`tips.profile.get|update`, `tips.checkout.create`, `tips.superchat.create`, `tips.tts.request`,
-`tips.media_request.create`, `tips.interaction.get|list|record|moderate`, `tips.goal.create|update|close`,
+`tips.media_request.create`, `tips.sound.request`, `tips.interaction.get|list|record|moderate`, `tips.goal.create|update|close`,
`tips.overlay.token.create|revoke`, `tips.overlay.config.get|update` and `tips.simulation.run`.
`tips.overlay.token.create|revoke`, `tips.overlay.config.get|update` and `tips.simulation.run`.
Called elsewhere, as the service principal `tips`:
Called elsewhere, as the service principal `tips`:
@@ -169,6 +169,8 @@ token as a Bearer and act on their own things only. The API never reads cookies.
@@ -169,6 +169,8 @@ token as a Bearer and act on their own things only. The API never reads cookies.
| `POST /paid-messages` | `tips.superchat.create` | the supporter |
| `POST /paid-messages` | `tips.superchat.create` | the supporter |
| `POST /tts-requests` `{…, tts: {text, voice}}` | `tips.tts.request` | the supporter |
| `POST /tts-requests` `{…, tts: {text, voice}}` | `tips.tts.request` | the supporter |
| `POST /media-requests` `{…, media: {url}}` | `tips.media_request.create` | the supporter |
| `POST /media-requests` `{…, media: {url}}` | `tips.media_request.create` | the supporter |
+| `POST /sound-requests` `{…, sound: {id}}` | `tips.sound.request` | the supporter |
+| `GET /profiles/:creator/sounds` | `tips.profile.get` (switched-off pages) | anyone when the page is on; the owner |
| `GET /interactions/:id` | `tips.interaction.get` | its creator or supporter |
| `GET /interactions/:id` | `tips.interaction.get` | its creator or supporter |
| `GET /interactions?creator=\|supporter=&cursor=` | `tips.interaction.list` | own receipts; `?as=creator` own tips |
| `GET /interactions?creator=\|supporter=&cursor=` | `tips.interaction.list` | own receipts; `?as=creator` own tips |
| `POST /interactions/external` `{creator, provider, provider_ref, amount_cents, supporter_name?, message?, announce?}` | `tips.interaction.record` | — |
| `POST /interactions/external` `{creator, provider, provider_ref, amount_cents, supporter_name?, message?, announce?}` | `tips.interaction.record` | — |
@@ -309,6 +311,14 @@ with Tips' service token (audience `openvibe.chat`; OpenVibe.Chat `docs/chat-ing
@@ -309,6 +311,14 @@ with Tips' service token (audience `openvibe.chat`; OpenVibe.Chat `docs/chat-ing
| `tts` | `POST /internal/chat/messages` | a `tts` line (`message` = the filtered TTS text) with `tts { voice, identity_key: tips:<interaction>, key: tips-<interaction> }`: Chat saves it and reads it aloud |
| `tts` | `POST /internal/chat/messages` | a `tts` line (`message` = the filtered TTS text) with `tts { voice, identity_key: tips:<interaction>, key: tips-<interaction> }`: Chat saves it and reads it aloud |
| `media_request` | none | fails at once: the media queue is not Chat's |
| `media_request` | none | fails at once: the media queue is not Chat's |
+**Paid sounds** (`kind: sound`, `server/domain/sounds.js`): a creator keeps up to 12 sounds (MP3, OGG or WAV of
+512 KB at most, recognised by their bytes), uploaded from the dashboard with a plain multipart form
+(`server/web/multipart.js`) and served from `/sounds/<id>` as their own audio type (`nosniff`, a sandboxing CSP,
+cached for good: a file never changes). With paid sounds on (`sounds_enabled`, minimum `sound_min`, 50 Vibes on a
+new page), a supporter picks one on the tip page or through `POST /api/v1/sound-requests`. After settlement the
+overlay alert carries the sound and the overlay plays it in place of the alert sound; the chat line reads
+"<name> played “<sound>” for N Vibes".
+
Every body's `key` is the effect's delivery id (`<interaction>:<effect>`; the alert `<delivery id>:alert`):
Every body's `key` is the effect's delivery id (`<interaction>:<effect>`; the alert `<delivery id>:alert`):
Chat applies a key once and answers a repeat with the first result, so a retry after a 5xx or a lost answer
Chat applies a key once and answers a repeat with the first result, so a retry after a 5xx or a lost answer
never posts twice. A 4xx (other than 401/408/425/429) fails the effect at once, everything else is retried
never posts twice. A 4xx (other than 401/408/425/429) fails the effect at once, everything else is retried
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+-- phase: expand
+-- Paid sounds (server/domain/sounds.js): a creator keeps up to 12 short sounds (MP3, OGG or WAV, 512 KB each),
+-- supporters pay to play one, and the creator's overlay plays it on stream. A sound's file never changes, so its
+-- URL (/sounds/<id>) is cached for good; deleting one removes the file, and the interactions that played it keep
+-- its name in their request.
+CREATE TABLE IF NOT EXISTS creator_sounds (
+ id text PRIMARY KEY, -- tsnd_<ULID>
+ creator_subject text NOT NULL,
+ name text NOT NULL,
+ mime text NOT NULL CHECK (mime IN ('audio/mpeg', 'audio/ogg', 'audio/wav')),
+ bytes integer NOT NULL CHECK (bytes > 0 AND bytes <= 524288),
+ sha256 text NOT NULL,
+ data bytea NOT NULL,
+ created_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS creator_sounds_creator ON creator_sounds (creator_subject, created_at, id);
+
+ALTER TABLE creator_tip_profiles ADD COLUMN IF NOT EXISTS sounds_enabled boolean NOT NULL DEFAULT false;
+ALTER TABLE creator_tip_profiles ADD COLUMN IF NOT EXISTS sound_min bigint NOT NULL DEFAULT 50;
+
+ALTER TABLE tip_interactions DROP CONSTRAINT IF EXISTS tip_interactions_kind_check;
+ALTER TABLE tip_interactions ADD CONSTRAINT tip_interactions_kind_check
+ CHECK (kind IN ('tip', 'paid_message', 'tts', 'media_request', 'sound'));
@@ -6,6 +6,7 @@ html, body { margin: 0; background: transparent; color: #fff; font: 600 28px/1.3
@@ -6,6 +6,7 @@ html, body { margin: 0; background: transparent; color: #fff; font: 600 28px/1.3
#alert img { max-width: 220px; max-height: 160px; display: block; margin: 0 auto 10px; }
#alert img { max-width: 220px; max-height: 160px; display: block; margin: 0 auto 10px; }
#alert .title b { color: #60a5fa; }
#alert .title b { color: #60a5fa; }
#alert .msg { font-weight: 500; font-size: 24px; margin-top: 8px; overflow-wrap: anywhere; }
#alert .msg { font-weight: 500; font-size: 24px; margin-top: 8px; overflow-wrap: anywhere; }
+#alert .sound { font-weight: 700; font-size: 22px; margin-top: 6px; color: #4ade80; overflow-wrap: anywhere; }
#alert .test { font-size: 14px; color: #fbbf24; letter-spacing: .08em; text-transform: uppercase; }
#alert .test { font-size: 14px; color: #fbbf24; letter-spacing: .08em; text-transform: uppercase; }
#goals { position: fixed; left: 24px; right: 24px; bottom: 24px; display: grid; gap: 10px; }
#goals { position: fixed; left: 24px; right: 24px; bottom: 24px; display: grid; gap: 10px; }
.g { background: rgba(10, 15, 28, .78); border-radius: 12px; padding: 10px 14px; font-size: 20px; }
.g { background: rgba(10, 15, 28, .78); border-radius: 12px; padding: 10px 14px; font-size: 20px; }
@@ -59,8 +59,10 @@ dl.receipt dd { margin: 0; overflow-wrap: anywhere; }
@@ -59,8 +59,10 @@ dl.receipt dd { margin: 0; overflow-wrap: anywhere; }
.creators a { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; background: var(--tips-card); text-decoration: none; color: inherit; }
.creators a { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; background: var(--tips-card); text-decoration: none; color: inherit; }
.creators img { border-radius: 50%; }
.creators img { border-radius: 50%; }
.steps li { margin: 8px 0; }
.steps li { margin: 8px 0; }
-.js .tip-form .when-tts, .js .tip-form .when-media { display: none; }
-.js .tip-form[data-kind=tts] .when-tts, .js .tip-form[data-kind=media_request] .when-media { display: block; }
+.js .tip-form .when-tts, .js .tip-form .when-media, .js .tip-form .when-sound { display: none; }
+.js .tip-form[data-kind=tts] .when-tts, .js .tip-form[data-kind=media_request] .when-media, .js .tip-form[data-kind=sound] .when-sound { display: block; }
+.sounds .sound-pick { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.sounds audio { height: 32px; max-width: 100%; }
.js .tip-form:not([data-kind=tip]) .when-tip { display: none; }
.js .tip-form:not([data-kind=tip]) .when-tip { display: none; }
ol.leaderboard { padding-left: 1.6em; }
ol.leaderboard { padding-left: 1.6em; }
ol.leaderboard li, ul.supporters li { margin: 4px 0; }
ol.leaderboard li, ul.supporters li { margin: 4px 0; }
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
var current = null;
var current = null;
var retracted = {};
var retracted = {};
var shown = {};
var shown = {};
- var HIDDEN = { tip: 'sent a tip', paid_message: 'sent a paid message', tts: 'sent a message to read out', media_request: 'requested media' };
+ var HIDDEN = { tip: 'sent a tip', paid_message: 'sent a paid message', tts: 'sent a message to read out', media_request: 'requested media', sound: 'played a sound' };
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function fmt(n) { try { return Number(n).toLocaleString('en-US'); } catch (e) { return String(n); } }
function fmt(n) { try { return Number(n).toLocaleString('en-US'); } catch (e) { return String(n); } }
@@ -51,11 +51,14 @@
@@ -51,11 +51,14 @@
else title.appendChild(document.createTextNode(p));
else title.appendChild(document.createTextNode(p));
});
});
box.appendChild(title);
box.appendChild(title);
+ if (a.sound && a.sound.name) box.appendChild(el('div', 'sound', '\u266a ' + a.sound.name));
var text = a.tts ? a.tts.text : a.message;
var text = a.tts ? a.tts.text : a.message;
if (s.show_message !== false && text) box.appendChild(el('div', 'msg', text));
if (s.show_message !== false && text) box.appendChild(el('div', 'msg', text));
alertEl.appendChild(box);
alertEl.appendChild(box);
alertEl.className = 'show';
alertEl.className = 'show';
- if (s.sound_url) { try { new Audio(s.sound_url).play().catch(function () {}); } catch (e) { /* no audio */ } }
+ // A paid sound plays instead of the alert sound: it is what the supporter paid for.
+ var audio = a.sound && a.sound.url ? a.sound.url : s.sound_url;
+ if (audio) { try { new Audio(audio).play().catch(function () {}); } catch (e) { /* no audio */ } }
if (s.speak_message && a.tts && window.speechSynthesis) {
if (s.speak_message && a.tts && window.speechSynthesis) {
try { window.speechSynthesis.speak(new SpeechSynthesisUtterance(a.tts.text)); } catch (e) { /* no voice */ }
try { window.speechSynthesis.speak(new SpeechSynthesisUtterance(a.tts.text)); } catch (e) { /* no voice */ }
}
}
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
* | POST /paid-messages | tips.superchat.create | the supporter |
* | POST /paid-messages | tips.superchat.create | the supporter |
* | POST /tts-requests | tips.tts.request | the supporter |
* | POST /tts-requests | tips.tts.request | the supporter |
* | POST /media-requests | tips.media_request.create | the supporter |
* | POST /media-requests | tips.media_request.create | the supporter |
+ * | POST /sound-requests | tips.sound.request | the supporter |
* | GET /interactions/:id | tips.interaction.get | its creator or supporter |
* | GET /interactions/:id | tips.interaction.get | its creator or supporter |
* | GET /interactions | tips.interaction.list | own receipts; ?as=creator own tips |
* | GET /interactions | tips.interaction.list | own receipts; ?as=creator own tips |
* | POST /interactions/external | tips.interaction.record | — |
* | POST /interactions/external | tips.interaction.record | — |
@@ -28,6 +29,7 @@
@@ -28,6 +29,7 @@
* | POST /overlay-configs, PATCH …/:id | tips.overlay.config.update | owner |
* | POST /overlay-configs, PATCH …/:id | tips.overlay.config.update | owner |
* | POST /simulate | tips.simulation.run | owner |
* | POST /simulate | tips.simulation.run | owner |
* | GET /profiles/:creator/supporters | tips.profile.get (page off) | anyone when the page shows it; owner|
* | GET /profiles/:creator/supporters | tips.profile.get (page off) | anyone when the page shows it; owner|
+ * | GET /profiles/:creator/sounds | tips.profile.get (page off) | anyone when the page is on; owner |
* | GET /me/export, POST /me/erase | — | the supporter's own tips |
* | GET /me/export, POST /me/erase | — | the supporter's own tips |
* | GET /moderation?creator=&state= | tips.interaction.moderate | the creator and their moderators |
* | GET /moderation?creator=&state= | tips.interaction.moderate | the creator and their moderators |
* | POST /interactions/:id/hide|restore | tips.interaction.moderate | the creator and their moderators |
* | POST /interactions/:id/hide|restore | tips.interaction.moderate | the creator and their moderators |
@@ -50,6 +52,7 @@ const CAP = {
@@ -50,6 +52,7 @@ const CAP = {
superchat: 'tips.superchat.create',
superchat: 'tips.superchat.create',
tts: 'tips.tts.request',
tts: 'tips.tts.request',
media: 'tips.media_request.create',
media: 'tips.media_request.create',
+ sound: 'tips.sound.request',
interactionGet: 'tips.interaction.get',
interactionGet: 'tips.interaction.get',
interactionList: 'tips.interaction.list',
interactionList: 'tips.interaction.list',
interactionRecord: 'tips.interaction.record',
interactionRecord: 'tips.interaction.record',
@@ -109,6 +112,16 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -109,6 +112,16 @@ function v1Router({ domain, apiAuth, limits }) {
res.json({ profile: profiles.present(p, { owner }), goals: owner ? await goals.presentMany(db, active) : await goals.publicGoals(db, active, p) });
res.json({ profile: profiles.present(p, { owner }), goals: owner ? await goals.presentMany(db, active) : await goals.publicGoals(db, active, p) });
}));
}));
+ /** The creator's paid sounds (what a supporter may pick for POST /sound-requests), when they are switched on. */
+ r.get('/profiles/:creator/sounds', wrap(async (req, res) => {
+ const p = await creatorOf(req, req.params.creator);
+ const who = req.principal;
+ const owner = (who.kind === 'user' && who.subject === p.creator_subject) || (who.kind === 'service' && apiAuth.granted(who, CAP.profileGet));
+ if (!p.page_enabled && !owner) fail(404, 'tips.creator_not_found', 'no tip profile for this creator');
+ const list = p.sounds_enabled || owner ? await domain.sounds.list(db, p.creator_subject) : [];
+ res.json({ creator: { type: 'user', id: p.creator_subject }, enabled: !!p.sounds_enabled, minimum: Number(p.sound_min), sounds: list.map((x) => ({ ...x, url: `${domain.config.baseUrl}${x.url}` })) });
+ }));
+
/** The supporters page: top supporters and recent public messages, as the creator chose to show them. */
/** The supporters page: top supporters and recent public messages, as the creator chose to show them. */
r.get('/profiles/:creator/supporters', wrap(async (req, res) => {
r.get('/profiles/:creator/supporters', wrap(async (req, res) => {
const p = await creatorOf(req, req.params.creator);
const p = await creatorOf(req, req.params.creator);
@@ -209,6 +222,7 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -209,6 +222,7 @@ function v1Router({ domain, apiAuth, limits }) {
r.post('/paid-messages', pays, ...paid('paid_message', CAP.superchat));
r.post('/paid-messages', pays, ...paid('paid_message', CAP.superchat));
r.post('/tts-requests', pays, ...paid('tts', CAP.tts));
r.post('/tts-requests', pays, ...paid('tts', CAP.tts));
r.post('/media-requests', pays, ...paid('media_request', CAP.media));
r.post('/media-requests', pays, ...paid('media_request', CAP.media));
+ r.post('/sound-requests', pays, ...paid('sound', CAP.sound));
// ── Interactions ─────────────────────────────────────────
// ── Interactions ─────────────────────────────────────────
// The hottest list: a keyset page of any size costs three queries (the page, the creators' filters,
// The hottest list: a keyset page of any size costs three queries (the page, the creators' filters,
@@ -32,6 +32,8 @@ const TABLES = [
@@ -32,6 +32,8 @@ const TABLES = [
{ table: 'tip_interactions', subject: 'creator_subject', file: 'tips-received.json', columns: ['id', 'kind', 'amount', 'currency', 'payment_state', 'settlement', 'origin', 'created_at', 'settled_at', 'reversed_at'], erase: { keep: money } },
{ table: 'tip_interactions', subject: 'creator_subject', file: 'tips-received.json', columns: ['id', 'kind', 'amount', 'currency', 'payment_state', 'settlement', 'origin', 'created_at', 'settled_at', 'reversed_at'], erase: { keep: money } },
{ table: 'paid_messages', subject: 'creator_subject', file: null, erase: { keep: money } },
{ table: 'paid_messages', subject: 'creator_subject', file: null, erase: { keep: money } },
{ table: 'paid_media_requests', subject: 'creator_subject', file: null, erase: { keep: money } },
{ table: 'paid_media_requests', subject: 'creator_subject', file: null, erase: { keep: money } },
+ // The creator's paid sounds: listed in the export (each file is at its /sounds/<id> link), deleted on erasure.
+ { table: 'creator_sounds', subject: 'creator_subject', file: 'sounds.json', columns: ['id', 'name', 'mime', 'bytes', 'created_at'] },
{ table: 'tip_moderation_log', subject: 'creator_subject', file: null, erase: { keep: 'the creator\'s moderation log, kept for accountability' } },
{ table: 'tip_moderation_log', subject: 'creator_subject', file: null, erase: { keep: 'the creator\'s moderation log, kept for accountability' } },
{ table: 'tip_moderation_log', subject: 'actor', file: null, erase: { keep: 'the moderation log names the moderator, for the creator\'s accountability' }, kind: 'tip_moderation_log' },
{ table: 'tip_moderation_log', subject: 'actor', file: null, erase: { keep: 'the moderation log names the moderator, for the creator\'s accountability' }, kind: 'tip_moderation_log' },
];
];
@@ -56,6 +56,7 @@ function createEffects(ctx) {
@@ -56,6 +56,7 @@ function createEffects(ctx) {
if (effect.effect === 'paid_message') job.highlight_seconds = req.highlight_seconds || 0;
if (effect.effect === 'paid_message') job.highlight_seconds = req.highlight_seconds || 0;
if (effect.effect === 'tts') job.tts = pv.tts;
if (effect.effect === 'tts') job.tts = pv.tts;
if (effect.effect === 'media_request') { job.media = req.media; job.text = amount ? `${name} requested media for ${amount}` : `${name} requested media`; }
if (effect.effect === 'media_request') { job.media = req.media; job.text = amount ? `${name} requested media for ${amount}` : `${name} requested media`; }
+ if (effect.effect === 'chat_line' && i.kind === 'sound' && req.sound) job.text = `${name} played \u201c${req.sound.name}\u201d${amount ? ` for ${amount}` : ''}${msg}`;
return job;
return job;
}
}
@@ -33,6 +33,7 @@ const { createOverlays } = require('./overlays');
@@ -33,6 +33,7 @@ const { createOverlays } = require('./overlays');
const { createInteractions } = require('./interactions');
const { createInteractions } = require('./interactions');
const { createEffects } = require('./effects');
const { createEffects } = require('./effects');
const { createModeration } = require('./moderation');
const { createModeration } = require('./moderation');
+const { createSounds } = require('./sounds');
const MONEY = { isolation: 'serializable' };
const MONEY = { isolation: 'serializable' };
@@ -59,6 +60,7 @@ function createDomain({ db, config, outbox, billing, adapters, valkey = null, no
@@ -59,6 +60,7 @@ function createDomain({ db, config, outbox, billing, adapters, valkey = null, no
ctx.outboxKick = () => { try { Promise.resolve(outbox.kick()).catch(() => {}); } catch { /* relay off */ } };
ctx.outboxKick = () => { try { Promise.resolve(outbox.kick()).catch(() => {}); } catch { /* relay off */ } };
ctx.profiles = createProfiles(ctx);
ctx.profiles = createProfiles(ctx);
+ ctx.sounds = createSounds(ctx);
ctx.goals = createGoals(ctx);
ctx.goals = createGoals(ctx);
ctx.overlays = createOverlays(ctx);
ctx.overlays = createOverlays(ctx);
ctx.interactions = createInteractions(ctx);
ctx.interactions = createInteractions(ctx);
@@ -49,7 +49,7 @@ const { parsePrivacy, publicName, shownName, privacyOf, isAnonymous, publicView,
@@ -49,7 +49,7 @@ const { parsePrivacy, publicName, shownName, privacyOf, isAnonymous, publicView,
const { BillingCallError } = require('../billing-client');
const { BillingCallError } = require('../billing-client');
const { ENVELOPE } = require('../events/outbox');
const { ENVELOPE } = require('../events/outbox');
-const KINDS = ['tip', 'paid_message', 'tts', 'media_request'];
+const KINDS = ['tip', 'paid_message', 'tts', 'media_request', 'sound'];
// Origins whose chat effects Tips delivers: its own requests, and EXTERNAL tips Billing announced
// Origins whose chat effects Tips delivers: its own requests, and EXTERNAL tips Billing announced
// (their only announcement since the PowerChat webhook moved from Live to Billing).
// (their only announcement since the PowerChat webhook moved from Live to Billing).
const ANNOUNCED_HERE = ['tips', 'billing-external'];
const ANNOUNCED_HERE = ['tips', 'billing-external'];
@@ -103,7 +103,7 @@ function createInteractions(ctx) {
@@ -103,7 +103,7 @@ function createInteractions(ctx) {
/**
/**
* Validate a request against the creator's profile. Returns the normalised fields:
* Validate a request against the creator's profile. Returns the normalised fields:
- * { kind, amount, message, request: { goal_id, tts, media, highlight_seconds } }.
+ * { kind, amount, message, request: { goal_id, tts, media, sound, highlight_seconds } }.
*/
*/
async function validate(q, profile, input, { simulation = false } = {}) {
async function validate(q, profile, input, { simulation = false } = {}) {
if (!profile) fail(404, 'tips.creator_not_found', 'this creator has no tip page');
if (!profile) fail(404, 'tips.creator_not_found', 'this creator has no tip page');
@@ -136,6 +136,14 @@ function createInteractions(ctx) {
@@ -136,6 +136,14 @@ function createInteractions(ctx) {
min(profile.media_request_min, 'a media request');
min(profile.media_request_min, 'a media request');
request.media = { ...mediaUrl(input.media && input.media.url), max_seconds: profile.media_max_seconds };
request.media = { ...mediaUrl(input.media && input.media.url), max_seconds: profile.media_max_seconds };
}
}
+ if (kind === 'sound') {
+ if (!profile.sounds_enabled) fail(409, 'tips.sounds_disabled', `${profile.display_name} has paid sounds switched off`);
+ min(Number(profile.sound_min), 'a sound');
+ const id = input.sound && typeof input.sound === 'object' ? input.sound.id : input.sound_id;
+ const sound = await ctx.sounds.ofCreator(q, profile.creator_subject, id);
+ if (!sound) fail(422, 'tips.sound_not_found', `pick one of ${profile.display_name}'s sounds`);
+ request.sound = { id: sound.id, name: sound.name };
+ }
if (input.goal_id) {
if (input.goal_id) {
const g = await ctx.goals.get(q, input.goal_id);
const g = await ctx.goals.get(q, input.goal_id);
if (!g || g.creator_subject !== profile.creator_subject || g.status !== 'active') fail(422, 'tips.goal_not_found', 'that goal is not open on this page');
if (!g || g.creator_subject !== profile.creator_subject || g.status !== 'active') fail(422, 'tips.goal_not_found', 'that goal is not open on this page');
@@ -333,7 +341,7 @@ function createInteractions(ctx) {
@@ -333,7 +341,7 @@ function createInteractions(ctx) {
// announced (origin billing), never to a real chat room for a simulation.
// announced (origin billing), never to a real chat room for a simulation.
const adapter = test ? 'test' : config.chat.adapter;
const adapter = test ? 'test' : config.chat.adapter;
if (ANNOUNCED_HERE.includes(i.origin) && adapter !== 'none') {
if (ANNOUNCED_HERE.includes(i.origin) && adapter !== 'none') {
- const chat = { tip: ['chat_line'], paid_message: ['paid_message'], tts: ['chat_line', 'tts'], media_request: ['media_request'] }[i.kind] || [];
+ const chat = { tip: ['chat_line'], paid_message: ['paid_message'], tts: ['chat_line', 'tts'], media_request: ['media_request'], sound: ['chat_line'] }[i.kind] || [];
for (const effect of chat) effects.push({ effect, adapter, state: 'queued' });
for (const effect of chat) effects.push({ effect, adapter, state: 'queued' });
}
}
await t.exec(sql`INSERT INTO interaction_effects ${sql.insert(effects.map((e) => ({ interaction_id: i.id, ...e, created_at: at, updated_at: at })))}
await t.exec(sql`INSERT INTO interaction_effects ${sql.insert(effects.map((e) => ({ interaction_id: i.id, ...e, created_at: at, updated_at: at })))}
@@ -580,7 +588,8 @@ function createInteractions(ctx) {
@@ -580,7 +588,8 @@ function createInteractions(ctx) {
const out = {
const out = {
id: i.id, creator: { type: 'user', id: i.creator_subject }, supporter: i.supporter_subject && named ? { type: 'user', id: i.supporter_subject } : null,
id: i.id, creator: { type: 'user', id: i.creator_subject }, supporter: i.supporter_subject && named ? { type: 'user', id: i.supporter_subject } : null,
supporter_name: named ? (i.supporter_name || null) : publicName(i), kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null,
supporter_name: named ? (i.supporter_name || null) : publicName(i), kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null,
- tts: req.tts || null, media: req.media ? { url: req.media.url, provider: req.media.provider } : null, goal_id: req.goal_id || null,
+ tts: req.tts || null, media: req.media ? { url: req.media.url, provider: req.media.provider } : null,
+ sound: req.sound ? { id: req.sound.id, name: req.sound.name, url: `/sounds/${req.sound.id}` } : null, goal_id: req.goal_id || null,
funding: i.funding, settlement: i.settlement, origin: i.origin, test: !!i.test,
funding: i.funding, settlement: i.settlement, origin: i.origin, test: !!i.test,
privacy: privacyOf(i), public: publicView(i, { filter }), erased_at: i.erased_at || null,
privacy: privacyOf(i), public: publicView(i, { filter }), erased_at: i.erased_at || null,
moderation: { state: i.moderation, filtered: !!i.filtered, at: i.moderated_at || null },
moderation: { state: i.moderation, filtered: !!i.filtered, at: i.moderated_at || null },
@@ -80,7 +80,7 @@ function publicView(i, { at, filter: settings = null } = {}) {
@@ -80,7 +80,7 @@ function publicView(i, { at, filter: settings = null } = {}) {
test: !!i.test,
test: !!i.test,
at: at || i.settled_at || i.created_at,
at: at || i.settled_at || i.created_at,
};
};
- if (isHidden(i)) return { ...base, amount: null, supporter_name: null, message: null, tts: null, media: null, hidden: true };
+ if (isHidden(i)) return { ...base, amount: null, supporter_name: null, message: null, tts: null, media: null, sound: null, hidden: true };
const message = privateMsg || !i.message ? null : filter.apply(i.message, settings).text;
const message = privateMsg || !i.message ? null : filter.apply(i.message, settings).text;
const tts = i.kind === 'tts' && req.tts && !i.erased_at ? { text: filter.apply(req.tts.text, settings, 'speech').text, voice: req.tts.voice } : null;
const tts = i.kind === 'tts' && req.tts && !i.erased_at ? { text: filter.apply(req.tts.text, settings, 'speech').text, voice: req.tts.voice } : null;
return {
return {
@@ -89,6 +89,8 @@ function publicView(i, { at, filter: settings = null } = {}) {
@@ -89,6 +89,8 @@ function publicView(i, { at, filter: settings = null } = {}) {
message,
message,
tts,
tts,
media: i.kind === 'media_request' && req.media && !i.erased_at ? { url: req.media.url } : null,
media: i.kind === 'media_request' && req.media && !i.erased_at ? { url: req.media.url } : null,
+ // A paid sound: the overlay plays it (the creator's own file; nothing the supporter wrote).
+ sound: i.kind === 'sound' && req.sound ? { id: req.sound.id, name: req.sound.name, url: `/sounds/${req.sound.id}` } : null,
hidden: false,
hidden: false,
};
};
}
}
@@ -107,9 +107,9 @@ function createProfiles(ctx) {
@@ -107,9 +107,9 @@ function createProfiles(ctx) {
const LIMITS = {
const LIMITS = {
min_amount: [1, 1_000_000], paid_message_min: [1, 1_000_000], tts_min_amount: [1, 1_000_000],
min_amount: [1, 1_000_000], paid_message_min: [1, 1_000_000], tts_min_amount: [1, 1_000_000],
- media_request_min: [1, 1_000_000], media_max_seconds: [10, 3 * 3600],
+ media_request_min: [1, 1_000_000], media_max_seconds: [10, 3 * 3600], sound_min: [1, 1_000_000],
};
};
- const BOOLS = ['page_enabled', 'accepting', 'tts_enabled', 'media_requests_enabled'];
+ const BOOLS = ['page_enabled', 'accepting', 'tts_enabled', 'media_requests_enabled', 'sounds_enabled'];
const on = (v) => v === true || v === 'on' || v === '1' || v === 1;
const on = (v) => v === true || v === 'on' || v === '1' || v === 1;
/**
/**
@@ -197,9 +197,14 @@ function createProfiles(ctx) {
@@ -197,9 +197,14 @@ function createProfiles(ctx) {
const out = {
const out = {
creator: { type: 'user', id: p.creator_subject }, handle: p.handle, display_name: p.display_name, avatar_url: p.avatar_url || null,
creator: { type: 'user', id: p.creator_subject }, handle: p.handle, display_name: p.display_name, avatar_url: p.avatar_url || null,
headline: p.headline || null, page_enabled: p.page_enabled, accepting: p.accepting, url: `${ctx.config.baseUrl}/${p.handle}`,
headline: p.headline || null, page_enabled: p.page_enabled, accepting: p.accepting, url: `${ctx.config.baseUrl}/${p.handle}`,
- minimums: { tip: p.min_amount, paid_message: p.paid_message_min, tts: p.tts_enabled ? p.tts_min_amount : null, media_request: p.media_requests_enabled ? p.media_request_min : null },
+ minimums: {
+ tip: p.min_amount, paid_message: p.paid_message_min, tts: p.tts_enabled ? p.tts_min_amount : null,
+ media_request: p.media_requests_enabled ? p.media_request_min : null, sound: p.sounds_enabled ? Number(p.sound_min) : null,
+ },
tts: { enabled: p.tts_enabled, max_chars: p.tts_max_chars, voice: p.tts_voice },
tts: { enabled: p.tts_enabled, max_chars: p.tts_max_chars, voice: p.tts_voice },
media_requests: { enabled: p.media_requests_enabled, max_seconds: p.media_max_seconds },
media_requests: { enabled: p.media_requests_enabled, max_seconds: p.media_max_seconds },
+ // The sounds themselves: GET /api/v1/profiles/:creator/sounds (and the public page).
+ sounds: { enabled: !!p.sounds_enabled },
page: p.page,
page: p.page,
supporters_url: p.page.supporters_page ? `${ctx.config.baseUrl}/${p.handle}/supporters` : null,
supporters_url: p.page.supporters_page ? `${ctx.config.baseUrl}/${p.handle}/supporters` : null,
currency: 'vibes-bits',
currency: 'vibes-bits',
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+
+/**
+ * Paid sounds: a creator's short sounds that supporters pay to play on stream (the owner's "paid soundboard … triggers").
+ *
+ * list(q, creator) the creator's sounds, oldest first: { id, name, url, mime, bytes }
+ * get(q, id) one sound's row with its file (serving)
+ * add(creator, { name, data }) a new sound (the creator's profile row is locked: 12 at most)
+ * remove(creator, id) delete one of the creator's sounds
+ *
+ * A file is an MP3, OGG or WAV of at most 512 KB, recognised by its first bytes (never by a name or a header the
+ * browser sent), served from /sounds/<id> with its own type, no sniffing and a sandboxing CSP. A sound's file never
+ * changes, so the URL is cached for good.
+ */
+const crypto = require('crypto');
+const { sql } = require('openvibe-sdk/db');
+const { fail, prefixedId, iso, text } = require('../util');
+
+const MAX_SOUNDS = 12;
+const MAX_BYTES = 512 * 1024;
+const ID_RE = /^tsnd_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/** The audio type of a file from its first bytes, or null. */
+function sniff(buf) {
+ if (!Buffer.isBuffer(buf) || buf.length < 12) return null;
+ if (buf.slice(0, 3).toString('latin1') === 'ID3') return 'audio/mpeg';
+ if (buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0) return 'audio/mpeg'; // an MPEG audio frame sync
+ if (buf.slice(0, 4).toString('latin1') === 'OggS') return 'audio/ogg';
+ if (buf.slice(0, 4).toString('latin1') === 'RIFF' && buf.slice(8, 12).toString('latin1') === 'WAVE') return 'audio/wav';
+ return null;
+}
+
+const EXT = { 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'audio/wav': 'wav' };
+
+function createSounds(ctx) {
+ const { db } = ctx;
+ const url = (id) => `/sounds/${id}`;
+ const present = (r) => ({ id: r.id, name: r.name, url: url(r.id), mime: r.mime, bytes: Number(r.bytes) });
+
+ async function list(q, creator) {
+ const rows = await q.many(sql`SELECT id, name, mime, bytes FROM creator_sounds WHERE creator_subject = ${String(creator || '')} ORDER BY created_at, id`);
+ return rows.map(present);
+ }
+
+ async function get(q, id) {
+ if (!ID_RE.test(String(id || ''))) return null;
+ return q.maybe(sql`SELECT * FROM creator_sounds WHERE id = ${String(id)}`);
+ }
+
+ /** One of this creator's sounds (for a request), or null. */
+ async function ofCreator(q, creator, id) {
+ if (!ID_RE.test(String(id || ''))) return null;
+ const r = await q.maybe(sql`SELECT id, name, mime, bytes FROM creator_sounds WHERE id = ${String(id)} AND creator_subject = ${String(creator || '')}`);
+ return r ? present(r) : null;
+ }
+
+ async function add(creator, { name, data }) {
+ const label = text(name, 'name', 40);
+ if (!label) fail(422, 'tips.invalid_input', 'a sound needs a name');
+ if (!Buffer.isBuffer(data) || !data.length) fail(422, 'tips.invalid_sound', 'no file was sent');
+ if (data.length > MAX_BYTES) fail(413, 'tips.sound_too_large', `a sound can be at most ${MAX_BYTES / 1024} KB`);
+ const mime = sniff(data);
+ if (!mime) fail(415, 'tips.invalid_sound', 'a sound must be an MP3, OGG or WAV file');
+ return ctx.tx(async (t) => {
+ if (!(await ctx.lockCreator(t, creator))) fail(404, 'tips.profile_not_found', 'set up your tip page first');
+ const n = Number((await t.one(sql`SELECT count(*)::int AS n FROM creator_sounds WHERE creator_subject = ${creator}`)).n);
+ if (n >= MAX_SOUNDS) fail(409, 'tips.too_many_sounds', `you can keep ${MAX_SOUNDS} sounds; delete one first`);
+ const row = await t.one(sql`INSERT INTO creator_sounds (id, creator_subject, name, mime, bytes, sha256, data, created_at)
+ VALUES (${prefixedId('tsnd', ctx.now())}, ${creator}, ${label.replace(/[<>]/g, '')}, ${mime}, ${data.length}, ${crypto.createHash('sha256').update(data).digest('hex')}, ${data}, ${iso(ctx.now())})
+ RETURNING id, name, mime, bytes`);
+ return present(row);
+ });
+ }
+
+ async function remove(creator, id) {
+ if (!ID_RE.test(String(id || ''))) fail(404, 'tips.sound_not_found', 'no such sound');
+ const n = await db.exec(sql`DELETE FROM creator_sounds WHERE id = ${String(id)} AND creator_subject = ${String(creator || '')}`);
+ if (!n) fail(404, 'tips.sound_not_found', 'no such sound');
+ return true;
+ }
+
+ return { list, get, ofCreator, add, remove, sniff, EXT, MAX_SOUNDS, MAX_BYTES, ID_RE };
+}
+
+module.exports = { createSounds, sniff, MAX_SOUNDS, MAX_BYTES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+
+/**
+ * A small multipart/form-data reader for the one upload form (a creator's sound): text fields and at most one file,
+ * from a body already read in full and bounded by the route (express.raw with a limit). Returns null for anything
+ * that is not well-formed multipart, so a broken or forged body is refused rather than half-read.
+ *
+ * parse(buffer, contentType) → { fields: { name: value }, file: { field, filename, type, data } | null } | null
+ */
+const MAX_PARTS = 16;
+
+function boundaryOf(contentType) {
+ const m = /^multipart\/form-data\s*;(?:.*;)?\s*boundary=(?:"([^"]{1,70})"|([^\s;]{1,70}))/i.exec(String(contentType || ''));
+ return m ? (m[1] || m[2]) : null;
+}
+
+function headerParams(value) {
+ const out = {};
+ const re = /;\s*([a-z*]+)="((?:[^"\\]|\\.)*)"/gi;
+ let m;
+ while ((m = re.exec(value))) out[m[1].toLowerCase()] = m[2].replace(/\\(.)/g, '$1');
+ return out;
+}
+
+function parse(buf, contentType) {
+ const boundary = boundaryOf(contentType);
+ if (!boundary || !Buffer.isBuffer(buf)) return null;
+ const delim = Buffer.from(`--${boundary}`);
+ let pos = buf.indexOf(delim);
+ if (pos !== 0) return null;
+ const fields = {};
+ let file = null;
+ let parts = 0;
+ for (;;) {
+ pos += delim.length;
+ if (buf.slice(pos, pos + 2).toString('latin1') === '--') return { fields, file }; // the closing delimiter
+ if (buf.slice(pos, pos + 2).toString('latin1') !== '\r\n') return null;
+ pos += 2;
+ if (++parts > MAX_PARTS) return null;
+ const headEnd = buf.indexOf('\r\n\r\n', pos, 'latin1');
+ if (headEnd < 0) return null;
+ const headers = {};
+ for (const line of buf.slice(pos, headEnd).toString('utf8').split('\r\n')) {
+ const i = line.indexOf(':');
+ if (i > 0) headers[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();
+ }
+ const next = buf.indexOf(Buffer.concat([Buffer.from('\r\n'), delim]), headEnd + 4);
+ if (next < 0) return null;
+ const body = buf.slice(headEnd + 4, next);
+ const disp = headers['content-disposition'] || '';
+ if (!/^form-data\b/i.test(disp)) return null;
+ const p = headerParams(disp);
+ if (!p.name) return null;
+ if (p.filename !== undefined) {
+ if (file) return null; // one file per upload
+ file = { field: p.name, filename: p.filename, type: headers['content-type'] || null, data: Buffer.from(body) };
+ } else {
+ fields[p.name] = body.toString('utf8');
+ }
+ pos = next + 2; // past the CRLF, onto the delimiter
+ }
+}
+
+module.exports = { parse, boundaryOf };
@@ -11,7 +11,7 @@ const { VOICES } = require('../domain/profiles');
@@ -11,7 +11,7 @@ const { VOICES } = require('../domain/profiles');
const n = (v) => Number(v || 0).toLocaleString('en-US');
const n = (v) => Number(v || 0).toLocaleString('en-US');
const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');
const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');
-const KIND_LABEL = { tip: 'Tip', paid_message: 'Paid message', tts: 'Text-to-speech', media_request: 'Media request' };
+const KIND_LABEL = { tip: 'Tip', paid_message: 'Paid message', tts: 'Text-to-speech', media_request: 'Media request', sound: 'Sound' };
const PAY_LABEL = { pending: 'Payment pending', settled: 'Paid', reversed: 'Reversed', failed: 'Payment failed' };
const PAY_LABEL = { pending: 'Payment pending', settled: 'Paid', reversed: 'Reversed', failed: 'Payment failed' };
const DELIVERY_LABEL = { awaiting_payment: 'Waiting for payment', queued: 'Being delivered', delivered: 'Delivered', failed: 'Delivery failed', cancelled: 'Cancelled' };
const DELIVERY_LABEL = { awaiting_payment: 'Waiting for payment', queued: 'Being delivered', delivered: 'Delivered', failed: 'Delivery failed', cancelled: 'Cancelled' };
@@ -38,10 +38,10 @@ function errorBox(msg) { return msg ? `<p class="notice error" role="alert">${es
@@ -38,10 +38,10 @@ function errorBox(msg) { return msg ? `<p class="notice error" role="alert">${es
/**
/**
* What a new tip page asks for until its creator changes it: migrations/0001_initial.sql's column defaults
* What a new tip page asks for until its creator changes it: migrations/0001_initial.sql's column defaults
- * (creator_tip_profiles.min_amount, paid_message_min, tts_min_amount, media_request_min). test/pages.test.js reads
+ * (creator_tip_profiles.min_amount, paid_message_min, tts_min_amount, media_request_min; sound_min in 0005). test/pages.test.js reads
* the migration and fails if these drift, so the home page never quotes a number the service does not use.
* the migration and fails if these drift, so the home page never quotes a number the service does not use.
*/
*/
-const NEW_PAGE_MINIMUMS = Object.freeze({ tip: 1, paid_message: 100, tts: 100, media_request: 25 });
+const NEW_PAGE_MINIMUMS = Object.freeze({ tip: 1, paid_message: 100, tts: 100, media_request: 25, sound: 50 });
const vibes = (n) => `${n} ${n === 1 ? 'Vibe' : 'Vibes'}`;
const vibes = (n) => `${n} ${n === 1 ? 'Vibe' : 'Vibes'}`;
/** The home: the product's own words (openvibe-shared/showcase), then the creators who switched their page on. */
/** The home: the product's own words (openvibe-shared/showcase), then the creators who switched their page on. */
@@ -62,12 +62,13 @@ function home({ creators }) {
@@ -62,12 +62,13 @@ function home({ creators }) {
{ icon: 'ov:chat', title: 'A paid message', text: 'Your message, highlighted in their chat.' },
{ icon: 'ov:chat', title: 'A paid message', text: 'Your message, highlighted in their chat.' },
{ icon: 'ov:audio', title: 'Text-to-speech', text: 'Your message read out on stream, where the creator turned it on.' },
{ icon: 'ov:audio', title: 'Text-to-speech', text: 'Your message read out on stream, where the creator turned it on.' },
{ icon: 'ov:video', title: 'A media request', text: 'A video for their stream, up to the length they allow, where they turned it on.' },
{ icon: 'ov:video', title: 'A media request', text: 'A video for their stream, up to the length they allow, where they turned it on.' },
+ { icon: 'ov:audio', title: 'A sound', text: "One of the creator's sounds, played on stream by their overlay, where they turned it on." },
],
],
}) + showcase.steps({
}) + showcase.steps({
title: 'How it works',
title: 'How it works',
items: [
items: [
{ title: 'Pick a creator', text: 'Every creator who switched their page on has one at openvibe.tips/name.' },
{ title: 'Pick a creator', text: 'Every creator who switched their page on has one at openvibe.tips/name.' },
- { title: 'Choose what to send', text: "A tip, a paid message, text-to-speech or a media request, within the creator's own limits." },
+ { title: 'Choose what to send', text: "A tip, a paid message, text-to-speech, a media request or a sound, within the creator's own limits." },
{ title: 'Pay with Vibes', text: 'From your Vibes balance, or through checkout. Payments are handled by OpenVibe.Billing; your receipt shows when the payment settled and when your message was delivered.' },
{ title: 'Pay with Vibes', text: 'From your Vibes balance, or through checkout. Payments are handled by OpenVibe.Billing; your receipt shows when the payment settled and when your message was delivered.' },
],
],
}) + showcase.pricing({
}) + showcase.pricing({
@@ -78,6 +79,7 @@ function home({ creators }) {
@@ -78,6 +79,7 @@ function home({ creators }) {
{ name: 'Paid message', price: vibes(m.paid_message), items: ['Highlighted in chat', 'Shown by their stream overlay'] },
{ name: 'Paid message', price: vibes(m.paid_message), items: ['Highlighted in chat', 'Shown by their stream overlay'] },
{ name: 'Text-to-speech', price: vibes(m.tts), items: ['Read out on stream', 'Only where the creator turned it on'] },
{ name: 'Text-to-speech', price: vibes(m.tts), items: ['Read out on stream', 'Only where the creator turned it on'] },
{ name: 'Media request', price: vibes(m.media_request), items: ['A video for their stream', 'Only where the creator turned it on'] },
{ name: 'Media request', price: vibes(m.media_request), items: ['A video for their stream', 'Only where the creator turned it on'] },
+ { name: 'Sound', price: vibes(m.sound), items: ['Played on stream by their overlay', 'Only where the creator turned it on'] },
],
],
note: "The creator's page shows their own minimums before you pay.",
note: "The creator's page shows their own minimums before you pay.",
}) + `
}) + `
@@ -85,11 +87,13 @@ ${creators.length ? `<section class="sc-sec" id="creators" aria-labelledby="crea
@@ -85,11 +87,13 @@ ${creators.length ? `<section class="sc-sec" id="creators" aria-labelledby="crea
}
}
/** The tip form: every field works as a plain POST. */
/** The tip form: every field works as a plain POST. */
-function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, error }) {
+function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, error, sounds = [] }) {
const kinds = [['tip', 'Tip', profile.min_amount]];
const kinds = [['tip', 'Tip', profile.min_amount]];
kinds.push(['paid_message', 'Paid message (highlighted in chat)', Math.max(profile.min_amount, profile.paid_message_min)]);
kinds.push(['paid_message', 'Paid message (highlighted in chat)', Math.max(profile.min_amount, profile.paid_message_min)]);
if (profile.tts_enabled) kinds.push(['tts', 'Text-to-speech (read out on stream)', Math.max(profile.min_amount, profile.tts_min_amount)]);
if (profile.tts_enabled) kinds.push(['tts', 'Text-to-speech (read out on stream)', Math.max(profile.min_amount, profile.tts_min_amount)]);
if (profile.media_requests_enabled) kinds.push(['media_request', 'Media request', Math.max(profile.min_amount, profile.media_request_min)]);
if (profile.media_requests_enabled) kinds.push(['media_request', 'Media request', Math.max(profile.min_amount, profile.media_request_min)]);
+ const soundsOn = profile.sounds_enabled && sounds.length > 0;
+ if (soundsOn) kinds.push(['sound', 'A sound (played on stream)', Math.max(profile.min_amount, Number(profile.sound_min))]);
const kind = values.kind || 'tip';
const kind = values.kind || 'tip';
const active = goals.filter((g) => g.status === 'active');
const active = goals.filter((g) => g.status === 'active');
if (!profile.accepting) return `<section class="card"><p>${esc(profile.display_name)} is not accepting tips right now.</p></section>`;
if (!profile.accepting) return `<section class="card"><p>${esc(profile.display_name)} is not accepting tips right now.</p></section>`;
@@ -105,6 +109,9 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e
@@ -105,6 +109,9 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e
${profile.tts_enabled ? `<div class="when-tts"><label>Text to read out <small>(up to ${profile.tts_max_chars} characters; the message is used when empty)</small><textarea name="tts_text" maxlength="${profile.tts_max_chars}" rows="2">${esc(values.tts_text || '')}</textarea></label>
${profile.tts_enabled ? `<div class="when-tts"><label>Text to read out <small>(up to ${profile.tts_max_chars} characters; the message is used when empty)</small><textarea name="tts_text" maxlength="${profile.tts_max_chars}" rows="2">${esc(values.tts_text || '')}</textarea></label>
<label>Voice <select name="tts_voice">${VOICES.map((v) => `<option${v === (values.tts_voice || profile.tts_voice) ? ' selected' : ''}>${v}</option>`).join('')}</select></label></div>` : ''}
<label>Voice <select name="tts_voice">${VOICES.map((v) => `<option${v === (values.tts_voice || profile.tts_voice) ? ' selected' : ''}>${v}</option>`).join('')}</select></label></div>` : ''}
${profile.media_requests_enabled ? `<label class="when-media">Media link <small>(YouTube, up to ${Math.floor(profile.media_max_seconds / 60)} minutes)</small><input type="url" name="media_url" value="${esc(values.media_url || '')}" placeholder="https://www.youtube.com/watch?v=…"></label>` : ''}
${profile.media_requests_enabled ? `<label class="when-media">Media link <small>(YouTube, up to ${Math.floor(profile.media_max_seconds / 60)} minutes)</small><input type="url" name="media_url" value="${esc(values.media_url || '')}" placeholder="https://www.youtube.com/watch?v=…"></label>` : ''}
+ ${soundsOn ? `<fieldset class="when-sound sounds"><legend>Which sound</legend>
+ ${sounds.map((x, i) => `<label class="sound-pick"><input type="radio" name="sound_id" value="${esc(x.id)}"${(values.sound_id ? values.sound_id === x.id : i === 0) ? ' checked' : ''}> ${esc(x.name)} <audio controls preload="none" src="${esc(x.url)}"></audio></label>`).join('\n ')}
+ </fieldset>` : ''}
${active.length ? `<label>Count toward <select name="goal_id">${active.length > 1 ? '<option value="">No goal</option>' : ''}${active.map((g) => `<option value="${esc(g.id)}"${values.goal_id === g.id ? ' selected' : ''}>${esc(g.title)}</option>`).join('')}</select></label>` : ''}
${active.length ? `<label>Count toward <select name="goal_id">${active.length > 1 ? '<option value="">No goal</option>' : ''}${active.map((g) => `<option value="${esc(g.id)}"${values.goal_id === g.id ? ' selected' : ''}>${esc(g.title)}</option>`).join('')}</select></label>` : ''}
<fieldset class="pay"><legend>Pay with</legend>
<fieldset class="pay"><legend>Pay with</legend>
<label><input type="radio" name="pay_with" value="credit"${values.pay_with !== 'checkout' ? ' checked' : ''}> My Vibes balance</label>
<label><input type="radio" name="pay_with" value="credit"${values.pay_with !== 'checkout' ? ' checked' : ''}> My Vibes balance</label>
@@ -121,7 +128,7 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e
@@ -121,7 +128,7 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e
</form>`;
</form>`;
}
}
-function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, error, ownerPreview }) {
+function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, error, ownerPreview, sounds = [] }) {
const active = goals.filter((g) => g.status === 'active');
const active = goals.filter((g) => g.status === 'active');
return `${ownerPreview ? '<p class="notice">Your page is switched off: only you can see it, and search engines are told not to index it. Switch it on in the <a href="/dashboard">dashboard</a>.</p>' : ''}
return `${ownerPreview ? '<p class="notice">Your page is switched off: only you can see it, and search engines are told not to index it. Switch it on in the <a href="/dashboard">dashboard</a>.</p>' : ''}
<section class="creator-head">
<section class="creator-head">
@@ -130,7 +137,7 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er
@@ -130,7 +137,7 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er
<p class="links"><a href="https://openvibe.live/@${esc(profile.handle)}">Watch on OpenVibe.Live</a> · <a href="/${esc(profile.handle)}/goals">Goals</a>${profile.page.supporters_page ? ` · <a href="/${esc(profile.handle)}/supporters">Supporters</a>` : ''}</p></div>
<p class="links"><a href="https://openvibe.live/@${esc(profile.handle)}">Watch on OpenVibe.Live</a> · <a href="/${esc(profile.handle)}/goals">Goals</a>${profile.page.supporters_page ? ` · <a href="/${esc(profile.handle)}/supporters">Supporters</a>` : ''}</p></div>
</section>
</section>
${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}
${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}
-${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error })}`;
+${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}`;
}
}
function goalsPage({ profile, goals }) {
function goalsPage({ profile, goals }) {
@@ -214,7 +221,22 @@ ${i.payment.state === 'pending' && i.funding === 'checkout' && !i.checkout?.url
@@ -214,7 +221,22 @@ ${i.payment.state === 'pending' && i.funding === 'checkout' && !i.checkout?.url
<p class="fine">The payment state comes from OpenVibe.Billing. Delivery is tracked separately: a delivery problem never undoes a payment.</p>`;
<p class="fine">The payment state comes from OpenVibe.Billing. Delivery is tracked separately: a delivery problem never undoes a payment.</p>`;
}
}
-function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter, mod = {} }) {
+/** The dashboard's paid sounds: the list (with a player and a delete button) and the upload form (a plain multipart POST). */
+function soundsCard({ profile: p, sounds, csrf, idem }) {
+ const full = sounds.length >= 12;
+ return `<section class="card" id="sounds"><h2>Paid sounds</h2>
+ <p>Supporters pay to play one of these on stream; your overlay plays it with the alert${p.sounds_enabled ? '' : '. <b>Paid sounds are off</b>: switch them on in Your page above'}. MP3, OGG or WAV, up to 512 KB each, 12 at most.</p>
+ ${sounds.length ? `<ul class="sound-list">${sounds.map((x) => `<li><span class="sound-name">${esc(x.name)}</span> <audio controls preload="none" src="${esc(x.url)}"></audio>
+ <form method="post" action="/dashboard/sounds/${esc(x.id)}/delete">${hidden({ csrf, idem: `${idem}-sd-${x.id}` })}<button class="btn btn-small" type="submit">Delete</button></form></li>`).join('')}</ul>` : '<p class="muted">No sounds yet.</p>'}
+ ${full ? '<p class="muted">You have 12 sounds: delete one to add another.</p>' : `<form method="post" action="/dashboard/sounds" enctype="multipart/form-data" class="sound-upload">${hidden({ csrf, idem: `${idem}-su` })}
+ <label>Name <input type="text" name="name" maxlength="40" placeholder="Airhorn"></label>
+ <label>File <input type="file" name="file" accept="audio/mpeg,audio/ogg,audio/wav,.mp3,.ogg,.wav" required></label>
+ <button class="btn" type="submit">Add sound</button>
+ </form>`}
+</section>`;
+}
+
+function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter, mod = {}, sounds = [] }) {
const p = profile;
const p = profile;
const f = (k) => (p[k] ? ' checked' : '');
const f = (k) => (p[k] ? ' checked' : '');
const alertCfg = configs.find((c) => c.kind === 'alerts');
const alertCfg = configs.find((c) => c.kind === 'alerts');
@@ -242,6 +264,8 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
@@ -242,6 +264,8 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
<label><input type="checkbox" name="media_requests_enabled" value="1"${f('media_requests_enabled')}> Media requests</label>
<label><input type="checkbox" name="media_requests_enabled" value="1"${f('media_requests_enabled')}> Media requests</label>
<label>Minimum media request <input type="number" name="media_request_min" min="1" value="${p.media_request_min}"></label>
<label>Minimum media request <input type="number" name="media_request_min" min="1" value="${p.media_request_min}"></label>
<label>Longest media (seconds) <input type="number" name="media_max_seconds" min="10" max="10800" value="${p.media_max_seconds}"></label>
<label>Longest media (seconds) <input type="number" name="media_max_seconds" min="10" max="10800" value="${p.media_max_seconds}"></label>
+ <label><input type="checkbox" name="sounds_enabled" value="1"${f('sounds_enabled')}> Paid sounds <small>(your sounds, below, played by your overlay)</small></label>
+ <label>Minimum for a sound <input type="number" name="sound_min" min="1" value="${p.sound_min}"></label>
<fieldset><legend>Public pages</legend>
<fieldset><legend>Public pages</legend>
<label><input type="checkbox" name="page_goal_amounts" value="1"${p.page.goal_amounts ? ' checked' : ''}> Goals show their amounts <small>(off: the percentage only)</small></label>
<label><input type="checkbox" name="page_goal_amounts" value="1"${p.page.goal_amounts ? ' checked' : ''}> Goals show their amounts <small>(off: the percentage only)</small></label>
<label><input type="checkbox" name="page_goal_supporters" value="1"${p.page.goal_supporters ? ' checked' : ''}> Goals list their latest supporters</label>
<label><input type="checkbox" name="page_goal_supporters" value="1"${p.page.goal_supporters ? ' checked' : ''}> Goals list their latest supporters</label>
@@ -255,6 +279,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
@@ -255,6 +279,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
</form>
</form>
</div>
</div>
</section>
</section>
+${soundsCard({ profile: p, sounds, csrf, idem })}
<section class="card"><h2>Goals</h2>
<section class="card"><h2>Goals</h2>
${goals.length ? goals.map((g) => `${goalCard(g)}${g.status === 'active' ? `<form class="inline" method="post" action="/dashboard/goals/${esc(g.id)}">${hidden({ csrf, idem: `${idem}-g-${g.id}`, revision: g.revision })}
${goals.length ? goals.map((g) => `${goalCard(g)}${g.status === 'active' ? `<form class="inline" method="post" action="/dashboard/goals/${esc(g.id)}">${hidden({ csrf, idem: `${idem}-g-${g.id}`, revision: g.revision })}
<label>Title <input name="title" maxlength="120" value="${esc(g.title)}"></label><label>Target <input type="number" name="target_amount" min="1" value="${g.target_amount}"></label>
<label>Title <input name="title" maxlength="120" value="${esc(g.title)}"></label><label>Target <input type="number" name="target_amount" min="1" value="${g.target_amount}"></label>
@@ -293,11 +318,12 @@ ${moderationCard({ p, csrf, idem, mod })}
@@ -293,11 +318,12 @@ ${moderationCard({ p, csrf, idem, mod })}
<section class="card"><h2>Test your setup</h2>
<section class="card"><h2>Test your setup</h2>
<p>Runs the whole path — overlay alert, goal widget, delivery — marked as a simulation. Nothing is charged and nothing is counted. ${chatAdapter === 'none' ? '' : 'Chat delivery of simulations stays inside Tips.'}</p>
<p>Runs the whole path — overlay alert, goal widget, delivery — marked as a simulation. Nothing is charged and nothing is counted. ${chatAdapter === 'none' ? '' : 'Chat delivery of simulations stays inside Tips.'}</p>
<form method="post" action="/dashboard/simulate">${hidden({ csrf, idem: `${idem}-s` })}
<form method="post" action="/dashboard/simulate">${hidden({ csrf, idem: `${idem}-s` })}
- <label>Kind <select name="kind"><option value="tip">Tip</option><option value="paid_message">Paid message</option>${p.tts_enabled ? '<option value="tts">Text-to-speech</option>' : ''}${p.media_requests_enabled ? '<option value="media_request">Media request</option>' : ''}</select></label>
+ <label>Kind <select name="kind"><option value="tip">Tip</option><option value="paid_message">Paid message</option>${p.tts_enabled ? '<option value="tts">Text-to-speech</option>' : ''}${p.media_requests_enabled ? '<option value="media_request">Media request</option>' : ''}${p.sounds_enabled && sounds.length ? '<option value="sound">Sound</option>' : ''}</select></label>
<label>Amount <input type="number" name="amount" min="1" value="${Math.max(p.min_amount, p.paid_message_min, 100)}"></label>
<label>Amount <input type="number" name="amount" min="1" value="${Math.max(p.min_amount, p.paid_message_min, 100)}"></label>
<label>Name <input name="supporter_name" value="Test supporter" maxlength="80"></label>
<label>Name <input name="supporter_name" value="Test supporter" maxlength="80"></label>
<label>Message <input name="message" value="This is a test alert" maxlength="300"></label>
<label>Message <input name="message" value="This is a test alert" maxlength="300"></label>
<label class="when-media">Media link (for a media request) <input type="url" name="media_url" placeholder="https://www.youtube.com/watch?v=…"></label>
<label class="when-media">Media link (for a media request) <input type="url" name="media_url" placeholder="https://www.youtube.com/watch?v=…"></label>
+ ${p.sounds_enabled && sounds.length ? `<label>Sound (for a sound) <select name="sound_id">${sounds.map((x) => `<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select></label>` : ''}
<button class="btn" type="submit">Send a test</button>
<button class="btn" type="submit">Send a test</button>
</form>
</form>
</section>
</section>
@@ -35,6 +35,7 @@ const { asyncRouter } = require('./async-router');
@@ -35,6 +35,7 @@ const { asyncRouter } = require('./async-router');
const pages = require('./pages');
const pages = require('./pages');
const { asset } = require('./layout');
const { asset } = require('./layout');
const { PAGE_DEFAULTS } = require('../domain/profiles');
const { PAGE_DEFAULTS } = require('../domain/profiles');
+const multipart = require('./multipart');
// The sitemap's lastmod: when this server booted — a real date, computed once, never per request.
// The sitemap's lastmod: when this server booted — a real date, computed once, never per request.
const BOOT_AT = new Date().toISOString();
const BOOT_AT = new Date().toISOString();
@@ -51,7 +52,7 @@ const RECEIPT_FLASH = new Set(['Your data was erased from your tips']);
@@ -51,7 +52,7 @@ const RECEIPT_FLASH = new Set(['Your data was erased from your tips']);
function createWebRoutes({ domain, config, layout, userAuth }) {
function createWebRoutes({ domain, config, layout, userAuth }) {
// Every handler below may be async: its errors reach the app's error handler (the 500 page).
// Every handler below may be async: its errors reach the app's error handler (the 500 page).
const r = asyncRouter(express.Router());
const r = asyncRouter(express.Router());
- const { profiles, goals, interactions, overlays, moderation, db } = domain;
+ const { profiles, goals, interactions, overlays, moderation, sounds, db } = domain;
const withViewer = viewerMiddleware(userAuth);
const withViewer = viewerMiddleware(userAuth);
const form = express.urlencoded({ extended: false, limit: '32kb' });
const form = express.urlencoded({ extended: false, limit: '32kb' });
const secret = config.formSecret || crypto.randomBytes(32).toString('hex');
const secret = config.formSecret || crypto.randomBytes(32).toString('hex');
@@ -205,11 +206,12 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -205,11 +206,12 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
configs = await overlays.listConfigs(db, p.creator_subject);
configs = await overlays.listConfigs(db, p.creator_subject);
}
}
const s = p.creator_subject;
const s = p.creator_subject;
- const [recentRows, goalRows, tokens, totals, deliveries, connected, held, moderators, invites, log, moderating] = await Promise.all([
+ const [recentRows, goalRows, tokens, totals, deliveries, connected, held, moderators, invites, log, moderating, soundList] = await Promise.all([
interactions.list(db, { creator: s, limit: 20 }).then((out) => interactions.presentMany(db, out.rows, { viewer: 'owner' })),
interactions.list(db, { creator: s, limit: 20 }).then((out) => interactions.presentMany(db, out.rows, { viewer: 'owner' })),
goals.list(db, s).then((list) => goals.presentMany(db, list)),
goals.list(db, s).then((list) => goals.presentMany(db, list)),
overlays.listTokens(db, s), interactions.totals(db, s), overlays.recentDeliveries(db, s, 20), overlays.connected(s),
overlays.listTokens(db, s), interactions.totals(db, s), overlays.recentDeliveries(db, s, 20), overlays.connected(s),
moderation.heldCount(db, s), moderation.listModerators(db, s), moderation.openInvites(db, s), moderation.log(db, s, 20), moderation.moderatedBy(db, req.viewer.subject),
moderation.heldCount(db, s), moderation.listModerators(db, s), moderation.openInvites(db, s), moderation.log(db, s, 20), moderation.moderatedBy(db, req.viewer.subject),
+ sounds.list(db, s),
]);
]);
html(res, pageFor(req, {
html(res, pageFor(req, {
title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard',
title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard',
@@ -219,6 +221,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -219,6 +221,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
deliveries, csrf: csrfFor(req.viewer.subject), idem: idem(), flash, error,
deliveries, csrf: csrfFor(req.viewer.subject), idem: idem(), flash, error,
connected, chatAdapter: config.chat.adapter,
connected, chatAdapter: config.chat.adapter,
mod: { held, moderators: moderators.map(moderation.presentModerator), invites, log, moderating },
mod: { held, moderators: moderators.map(moderation.presentModerator), invites, log, moderating },
+ sounds: soundList,
}),
}),
}), status);
}), status);
}
}
@@ -248,6 +251,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -248,6 +251,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
const b = req.body;
const b = req.body;
await profiles.update(p.creator_subject, {
await profiles.update(p.creator_subject, {
page_enabled: bool(b.page_enabled), accepting: bool(b.accepting), tts_enabled: bool(b.tts_enabled), media_requests_enabled: bool(b.media_requests_enabled),
page_enabled: bool(b.page_enabled), accepting: bool(b.accepting), tts_enabled: bool(b.tts_enabled), media_requests_enabled: bool(b.media_requests_enabled),
+ sounds_enabled: bool(b.sounds_enabled), sound_min: b.sound_min,
headline: b.headline, min_amount: b.min_amount, paid_message_min: b.paid_message_min, tts_min_amount: b.tts_min_amount, tts_max_chars: b.tts_max_chars,
headline: b.headline, min_amount: b.min_amount, paid_message_min: b.paid_message_min, tts_min_amount: b.tts_min_amount, tts_max_chars: b.tts_max_chars,
tts_voice: b.tts_voice, media_request_min: b.media_request_min, media_max_seconds: b.media_max_seconds,
tts_voice: b.tts_voice, media_request_min: b.media_request_min, media_max_seconds: b.media_max_seconds,
page: Object.fromEntries(Object.keys(PAGE_DEFAULTS).map((k) => [k, bool(b[`page_${k}`])])),
page: Object.fromEntries(Object.keys(PAGE_DEFAULTS).map((k) => [k, bool(b[`page_${k}`])])),
@@ -296,10 +300,42 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -296,10 +300,42 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
await interactions.simulate(p, {
await interactions.simulate(p, {
kind: b.kind, amount: b.amount, message: b.message, supporter_name: b.supporter_name,
kind: b.kind, amount: b.amount, message: b.message, supporter_name: b.supporter_name,
tts: b.kind === 'tts' ? { text: b.message } : undefined, media: b.kind === 'media_request' ? { url: b.media_url } : undefined,
tts: b.kind === 'tts' ? { text: b.message } : undefined, media: b.kind === 'media_request' ? { url: b.media_url } : undefined,
+ sound: b.kind === 'sound' ? { id: b.sound_id } : undefined,
}, { by: req.viewer.subject });
}, { by: req.viewer.subject });
return { done: 'Test sent — check your overlay' };
return { done: 'Test sent — check your overlay' };
});
});
+ // ── Paid sounds (domain/sounds.js) ───────────────────────
+ // The upload is the one multipart form: read whole (bounded), parsed by ./multipart, refused when malformed.
+ const upload = express.raw({ type: 'multipart/form-data', limit: `${Math.ceil(sounds.MAX_BYTES / 1024) + 64}kb` });
+ r.post('/dashboard/sounds', withViewer, upload, async (req, res) => {
+ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/dashboard')}`);
+ const parsed = multipart.parse(req.body, req.get('content-type'));
+ req.body = parsed ? parsed.fields : {};
+ if (!csrfOk(req)) return await renderDashboard(req, res, { error: 'That form expired. Please try again.', status: 403 });
+ try {
+ if (!parsed || !parsed.file || !parsed.file.data.length) throw new TipsError(422, 'tips.invalid_sound', 'Choose a sound file to upload.');
+ const p = await ensureProfile(req);
+ await sounds.add(p.creator_subject, { name: parsed.fields.name || String(parsed.file.filename || '').replace(/\.[a-z0-9]{1,5}$/i, ''), data: parsed.file.data });
+ return res.redirect(303, `/dashboard?done=${encodeURIComponent('Sound added')}#sounds`);
+ } catch (e) {
+ if (e instanceof TipsError) return await renderDashboard(req, res, { error: e.detail || e.message, status: e.status >= 500 ? 500 : 400 });
+ throw e;
+ }
+ });
+ action('/dashboard/sounds/:id/delete', async (req, p) => { await sounds.remove(p.creator_subject, req.params.id); return { done: 'Sound deleted' }; });
+ // A sound's file never changes (no edit, only delete), so it is cached for good. Its own audio type, no sniffing,
+ // and a CSP that runs nothing: it is a file a creator uploaded.
+ r.get('/sounds/:id', async (req, res, next) => {
+ const row = await sounds.get(db, req.params.id);
+ if (!row) return next();
+ res.status(200).set({
+ 'Content-Type': row.mime, 'Content-Length': String(row.data.length), 'Cache-Control': 'public, max-age=31536000, immutable',
+ 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox", 'Cross-Origin-Resource-Policy': 'same-origin',
+ 'Content-Disposition': `inline; filename="${row.id}.${sounds.EXT[row.mime] || 'bin'}"`, ETag: `"${row.sha256}"`,
+ }).end(row.data);
+ });
+
// ── Overlays (token in the path; no cookies involved) ────
// ── Overlays (token in the path; no cookies involved) ────
const overlayHeaders = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex, nofollow' };
const overlayHeaders = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex, nofollow' };
async function tokenOr404(req, res) {
async function tokenOr404(req, res) {
@@ -430,7 +466,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -430,7 +466,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,
title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,
description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,
description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,
robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow', ogImage: p.avatar_url || undefined, jsonLd,
robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow', ogImage: p.avatar_url || undefined, jsonLd,
- body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled }),
+ body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled, sounds: p.sounds_enabled ? await sounds.list(db, p.creator_subject) : [] }),
}), status, p.page_enabled ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });
}), status, p.page_enabled ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });
}
}
r.get('/:handle', withViewer, async (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? await creatorView(req, res) : next()));
r.get('/:handle', withViewer, async (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? await creatorView(req, res) : next()));
@@ -466,7 +502,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -466,7 +502,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/${p.handle}`)}`);
if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/${p.handle}`)}`);
const b = req.body || {};
const b = req.body || {};
const values = {
const values = {
- kind: b.kind, amount: b.amount, message: b.message, tts_text: b.tts_text, tts_voice: b.tts_voice, media_url: b.media_url, goal_id: b.goal_id, pay_with: b.pay_with,
+ kind: b.kind, amount: b.amount, message: b.message, tts_text: b.tts_text, tts_voice: b.tts_voice, media_url: b.media_url, sound_id: b.sound_id, goal_id: b.goal_id, pay_with: b.pay_with,
supporter_name: b.supporter_name, anonymous: bool(b.anonymous), hide_amount: bool(b.hide_amount), private_message: bool(b.private_message),
supporter_name: b.supporter_name, anonymous: bool(b.anonymous), hide_amount: bool(b.hide_amount), private_message: bool(b.private_message),
};
};
if (!csrfOk(req)) return await creatorView(req, res, { values, error: 'That form expired. Please send it again.', status: 403 });
if (!csrfOk(req)) return await creatorView(req, res, { values, error: 'That form expired. Please send it again.', status: 403 });
@@ -478,6 +514,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -478,6 +514,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
kind: b.kind || 'tip', amount: b.amount, message: b.message, goal_id: b.goal_id || undefined,
kind: b.kind || 'tip', amount: b.amount, message: b.message, goal_id: b.goal_id || undefined,
tts: b.kind === 'tts' ? { text: b.tts_text || b.message, voice: b.tts_voice } : undefined,
tts: b.kind === 'tts' ? { text: b.tts_text || b.message, voice: b.tts_voice } : undefined,
media: b.kind === 'media_request' ? { url: b.media_url } : undefined,
media: b.kind === 'media_request' ? { url: b.media_url } : undefined,
+ sound: b.kind === 'sound' ? { id: b.sound_id } : undefined,
privacy: { anonymous: values.anonymous, hide_amount: values.hide_amount, private_message: values.private_message },
privacy: { anonymous: values.anonymous, hide_amount: values.hide_amount, private_message: values.private_message },
};
};
const funding = b.pay_with === 'checkout' ? 'checkout' : 'credit';
const funding = b.pay_with === 'checkout' ? 'checkout' : 'credit';
@@ -41,10 +41,11 @@ const { boot, check, done } = require('./helpers/app');
@@ -41,10 +41,11 @@ const { boot, check, done } = require('./helpers/app');
});
});
await check('the home quotes the minimums a new page really starts with (the migration defaults)', () => {
await check('the home quotes the minimums a new page really starts with (the migration defaults)', () => {
- const sql = require('fs').readFileSync(require('path').join(__dirname, '..', 'migrations', '0001_initial.sql'), 'utf8');
+ const read = (f) => require('fs').readFileSync(require('path').join(__dirname, '..', 'migrations', f), 'utf8');
+ const sql = read('0001_initial.sql') + read('0005_paid_sounds.sql');
const def = (col) => Number((sql.match(new RegExp(`\\b${col}\\s+bigint NOT NULL DEFAULT (\\d+)`)) || [])[1]);
const def = (col) => Number((sql.match(new RegExp(`\\b${col}\\s+bigint NOT NULL DEFAULT (\\d+)`)) || [])[1]);
const { NEW_PAGE_MINIMUMS: m } = require('../server/web/pages');
const { NEW_PAGE_MINIMUMS: m } = require('../server/web/pages');
- assert.deepStrictEqual(m, { tip: def('min_amount'), paid_message: def('paid_message_min'), tts: def('tts_min_amount'), media_request: def('media_request_min') });
+ assert.deepStrictEqual(m, { tip: def('min_amount'), paid_message: def('paid_message_min'), tts: def('tts_min_amount'), media_request: def('media_request_min'), sound: def('sound_min') });
});
});
await check('every page carries the boost marker and script, and the navbar signs in back to the current page', async () => {
await check('every page carries the boost marker and script, and the navbar signs in back to the current page', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+// Paid sounds (the owner's "paid soundboard … triggers"): a creator uploads short sounds from the dashboard (a plain
+// multipart form, no script needed), switches paid sounds on, a supporter pays to play one, the overlay alert
+// carries it and the chat line names it. Files are recognised by their bytes and served as audio that cannot run.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const multipart = require('../server/web/multipart');
+
+const MP3 = Buffer.concat([Buffer.from('ID3'), Buffer.from([4, 0, 0, 0, 0, 0, 0]), Buffer.alloc(200, 0x55)]);
+const OGG = Buffer.concat([Buffer.from('OggS'), Buffer.alloc(200, 0x11)]);
+const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x24, 0, 0, 0]), Buffer.from('WAVEfmt '), Buffer.alloc(200, 0)]);
+
+(async () => {
+ await check('multipart: fields and one file; anything malformed is refused, not half-read', async () => {
+ const b = 'XyZ123';
+ const body = Buffer.concat([
+ Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="csrf"\r\n\r\ntok\r\n`),
+ Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="file"; filename="horn.mp3"\r\nContent-Type: audio/mpeg\r\n\r\n`), MP3,
+ Buffer.from(`\r\n--${b}--\r\n`),
+ ]);
+ const out = multipart.parse(body, `multipart/form-data; boundary=${b}`);
+ assert.strictEqual(out.fields.csrf, 'tok');
+ assert.strictEqual(out.file.filename, 'horn.mp3');
+ assert.ok(out.file.data.equals(MP3), 'the file bytes, CRLFs inside it untouched');
+ assert.strictEqual(multipart.parse(body, 'multipart/form-data; boundary=other'), null);
+ assert.strictEqual(multipart.parse(body.slice(0, body.length - 10), `multipart/form-data; boundary=${b}`), null, 'a cut body');
+ const two = Buffer.concat([body.slice(0, body.length - `--${b}--\r\n`.length), Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="f2"; filename="x.mp3"\r\n\r\n`), MP3, Buffer.from(`\r\n--${b}--\r\n`)]);
+ assert.strictEqual(multipart.parse(two, `multipart/form-data; boundary=${b}`), null, 'one file per upload');
+ });
+
+ const t = await boot();
+ const { billing, domain } = t;
+ const alex = await t.creator('alex');
+ const bob = await t.creator('bob');
+ const viewer = t.network.newUser('viewer');
+ billing.fund(viewer.subject, 10_000);
+ const cookie = (u) => `ov_token=${t.network.signUser(u)}`;
+ const get = async (p, u) => { const r = await fetch(t.base + p, { headers: u ? { Cookie: cookie(u) } : {}, redirect: 'manual' }); return { status: r.status, headers: r.headers, text: await r.text(), r }; };
+ const field = (html, name) => { const m = html.match(new RegExp(`name="${name}" value="([^"]*)"`)); return m ? m[1] : null; };
+ const upload = async (u, { name, file, filename = 'sound.mp3', csrf }) => {
+ const fd = new FormData();
+ if (csrf !== null) fd.append('csrf', csrf || field((await get('/dashboard', u)).text, 'csrf'));
+ if (name != null) fd.append('name', name);
+ fd.append('file', new Blob([file]), filename);
+ const r = await fetch(`${t.base}/dashboard/sounds`, { method: 'POST', body: fd, redirect: 'manual', headers: { Cookie: cookie(u) } });
+ return { status: r.status, location: r.headers.get('location'), text: await r.text() };
+ };
+ let horn;
+
+ await check('the dashboard uploads a sound with a plain multipart form; the bytes decide what it is', async () => {
+ const page = await get('/dashboard', alex);
+ assert.match(page.text, /id="sounds"/);
+ assert.match(page.text, /enctype="multipart\/form-data"/);
+ const ok = await upload(alex, { name: 'Airhorn', file: MP3 });
+ assert.strictEqual(ok.status, 303, ok.text.slice(0, 300));
+ assert.match(ok.location, /done=Sound%20added/);
+ const fake = await upload(alex, { name: 'Not audio', file: Buffer.from('<script>alert(1)</script> and some more text'), filename: 'evil.mp3' });
+ assert.strictEqual(fake.status, 400);
+ assert.match(fake.text, /MP3, OGG or WAV/);
+ const forged = await upload(alex, { name: 'Forged', file: MP3, csrf: 'nope' });
+ assert.strictEqual(forged.status, 403);
+ assert.strictEqual((await upload(alex, { file: OGG, filename: 'Rimshot.ogg' })).status, 303, 'the file name names it when the name is empty');
+ const list = await domain.sounds.list(domain.db, alex.subject);
+ assert.deepStrictEqual(list.map((x) => [x.name, x.mime]), [['Airhorn', 'audio/mpeg'], ['Rimshot', 'audio/ogg']]);
+ horn = list[0];
+ await domain.sounds.add(bob.subject, { name: 'Bob sound', data: WAV });
+ });
+
+ await check('a sound is served as its own audio type, never sniffed, sandboxed and cached for good', async () => {
+ const r = await fetch(`${t.base}${horn.url}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('content-type'), 'audio/mpeg');
+ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff');
+ assert.match(r.headers.get('content-security-policy'), /default-src 'none'; sandbox/);
+ assert.match(r.headers.get('cache-control'), /immutable/);
+ assert.ok(Buffer.from(await r.arrayBuffer()).equals(MP3));
+ assert.strictEqual((await fetch(`${t.base}/sounds/tsnd_00000000000000000000000000`)).status, 404);
+ });
+
+ await check('switched off, nobody can buy a sound; switched on, the page offers them with a preview', async () => {
+ assert.ok(!(await get('/alex', viewer)).text.includes('name="sound_id"'));
+ const off = await t.call('POST', '/api/v1/sound-requests', { user: viewer, body: { creator: 'alex', amount: 100, sound: { id: horn.id } } });
+ assert.strictEqual(off.status, 409);
+ assert.strictEqual(off.json.code, 'tips.sounds_disabled');
+ const on = await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { sounds_enabled: true, sound_min: 60 } });
+ assert.strictEqual(on.status, 200, on.text);
+ assert.deepStrictEqual(on.json.profile.sounds, { enabled: true });
+ assert.strictEqual(on.json.profile.minimums.sound, 60);
+ const page = await get('/alex', viewer);
+ assert.match(page.text, /A sound \(played on stream\)/);
+ assert.match(page.text, new RegExp(`name="sound_id" value="${horn.id}" checked`));
+ assert.match(page.text, new RegExp(`<audio controls preload="none" src="/sounds/${horn.id}">`));
+ const api = await t.call('GET', '/api/v1/profiles/alex/sounds', { token: null });
+ assert.strictEqual(api.status, 200);
+ assert.deepStrictEqual(api.json.sounds.map((x) => x.url), [`http://tips.test/sounds/${horn.id}`, `http://tips.test${(await domain.sounds.list(domain.db, alex.subject))[1].url}`]);
+ assert.strictEqual(api.json.minimum, 60);
+ });
+
+ await check('a supporter pays to play one of the creator\'s sounds: the overlay plays it and chat names it', async () => {
+ const low = await t.call('POST', '/api/v1/sound-requests', { user: viewer, body: { creator: 'alex', amount: 10, sound: { id: horn.id } } });
+ assert.strictEqual(low.status, 422);
+ const foreign = (await domain.sounds.list(domain.db, bob.subject))[0];
+ const other = await t.call('POST', '/api/v1/sound-requests', { user: viewer, body: { creator: 'alex', amount: 100, sound: { id: foreign.id } } });
+ assert.strictEqual(other.status, 422, 'another creator\'s sound is not on this page');
+ const svc = await t.call('POST', '/api/v1/sound-requests', { cap: ['tips.checkout.create'], body: { creator: 'alex', supporter: viewer.subject, amount: 100, sound: { id: horn.id } } });
+ assert.strictEqual(svc.status, 403, 'a service needs tips.sound.request');
+ const r = await t.call('POST', '/api/v1/sound-requests', { user: viewer, body: { creator: 'alex', amount: 75, sound: { id: horn.id } } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.interaction.kind, 'sound');
+ assert.strictEqual(r.json.interaction.payment.state, 'settled');
+ assert.deepStrictEqual(r.json.interaction.sound, { id: horn.id, name: 'Airhorn', url: `/sounds/${horn.id}` });
+ const alerts = await domain.overlays.recentAlerts(domain.db, alex.subject);
+ assert.deepStrictEqual(alerts[0].sound, { id: horn.id, name: 'Airhorn', url: `/sounds/${horn.id}` }, 'the overlay alert carries the sound to play');
+ await domain.effects.drain();
+ const line = t.adapters.test.jobs.find((j) => j.interaction.id === r.json.interaction.id);
+ assert.ok(line, 'a chat line');
+ assert.strictEqual(line.effect, 'chat_line');
+ assert.strictEqual(line.text, 'Viewer played “Airhorn” for 75 Vibes');
+ });
+
+ await check('the creator tests a sound from the dashboard, deletes one, and keeps 12 at most', async () => {
+ const page = await get('/dashboard', alex);
+ assert.match(page.text, /<option value="sound">Sound<\/option>/);
+ const csrf = field(page.text, 'csrf');
+ const del = await fetch(`${t.base}/dashboard/sounds/${horn.id}/delete`, { method: 'POST', redirect: 'manual', headers: { Cookie: cookie(alex), 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ csrf }).toString() });
+ assert.strictEqual(del.status, 303);
+ assert.strictEqual((await fetch(`${t.base}${horn.url}`)).status, 404);
+ const left = (await domain.sounds.list(domain.db, alex.subject)).length;
+ for (let i = left; i < 12; i++) await domain.sounds.add(alex.subject, { name: `S${i}`, data: WAV });
+ await assert.rejects(domain.sounds.add(alex.subject, { name: 'Thirteenth', data: WAV }), (e) => e.code === 'tips.too_many_sounds');
+ await assert.rejects(domain.sounds.add(alex.subject, { name: 'Huge', data: Buffer.concat([MP3, Buffer.alloc(600 * 1024)]) }), (e) => e.code === 'tips.sound_too_large');
+ });
+
+ await done(t);
+})();