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)

OP OpenVibers committed 27 days ago · 365af51c82 · 18 files, +453 −31

Changes

ddiff --git a/README.md b/README.md
iindex 8223d1e..91558cd 100644
---- a/README.md
++++ b/README.md
@@@ -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, not
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*.
## 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 answer
never posts twice. A 4xx (other than 401/408/425/429) fails the effect at once, everything else is retried
ddiff --git a/migrations/0005_paid_sounds.sql b/migrations/0005_paid_sounds.sql
nnew file mode 100644
iindex 0000000..87dc52a
---- /dev/null
++++ b/migrations/0005_paid_sounds.sql
@@@ -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'));
ddiff --git a/public/css/overlay.css b/public/css/overlay.css
iindex 971b96b..f09c77f 100644
---- a/public/css/overlay.css
++++ b/public/css/overlay.css
@@@ -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; }
ddiff --git a/public/css/tips.css b/public/css/tips.css
iindex 78ab59f..cb5ff4e 100644
---- a/public/css/tips.css
++++ b/public/css/tips.css
@@@ -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; }
ddiff --git a/public/js/overlay.js b/public/js/overlay.js
iindex 7ba5b82..93178a7 100644
---- a/public/js/overlay.js
++++ b/public/js/overlay.js
@@@ -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 */ }
}
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 4276e18..3858b08 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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,
ddiff --git a/server/domain/account-data.js b/server/domain/account-data.js
iindex f7d9996..987dba5 100644
---- a/server/domain/account-data.js
++++ b/server/domain/account-data.js
@@@ -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' },
];
ddiff --git a/server/domain/effects.js b/server/domain/effects.js
iindex 99a84d0..407c363 100644
---- a/server/domain/effects.js
++++ b/server/domain/effects.js
@@@ -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;
}
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 700bc29..75f97dd 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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, no
ctx.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);
ddiff --git a/server/domain/interactions.js b/server/domain/interactions.js
iindex 53f34ed..6908b39 100644
---- a/server/domain/interactions.js
++++ b/server/domain/interactions.js
@@@ -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 },
ddiff --git a/server/domain/privacy.js b/server/domain/privacy.js
iindex bcaefec..05e8eed 100644
---- a/server/domain/privacy.js
++++ b/server/domain/privacy.js
@@@ -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,
};
}
ddiff --git a/server/domain/profiles.js b/server/domain/profiles.js
iindex 350a4ea..bb4db5f 100644
---- a/server/domain/profiles.js
++++ b/server/domain/profiles.js
@@@ -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',
ddiff --git a/server/domain/sounds.js b/server/domain/sounds.js
nnew file mode 100644
iindex 0000000..71baed7
---- /dev/null
++++ b/server/domain/sounds.js
@@@ -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 };
ddiff --git a/server/web/multipart.js b/server/web/multipart.js
nnew file mode 100644
iindex 0000000..3fe5df4
---- /dev/null
++++ b/server/web/multipart.js
@@@ -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 };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex fac99cd..58adb2a 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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>
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 554c7ca..c1d347f 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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';
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex b026d78..a5a0b41 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -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 () => {
ddiff --git a/test/sounds.test.js b/test/sounds.test.js
nnew file mode 100644
iindex 0000000..8983e79
---- /dev/null
++++ b/test/sounds.test.js
@@@ -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);
++})();