source: Klonkt/src/config/database.js@ e0a1ec1

main
Last change on this file since e0a1ec1 was f2eacca, checked in by roboburr <roboburr@…>, 2 months ago

feat(music): per-track "share on the fediverse" — federate the file as a native AS2 Audio attachment

A per-track opt-in (default off) so an OPEN track's audio file is federated as a real AS2 Audio
attachment and served ungated → it plays inline in EVERY fediverse client, incl. the official
Mastodon apps (which only play native media, not external player cards). Gated tracks (default)
keep the file hidden + web-player-only. This is the spec-canonical way to federate audio; the
gated path stays the deliberate anti-steal choice.

  • src/config/database.js — audio_tracks.fedi_open column (default 0)
  • src/routes/audio.js — /audio/stream serves fedi_open tracks ungated so remote servers can fetch them
  • src/services/ActivityPubService.js (buildNote) — fedi_open tracks → AS2 Audio attachments (the file URL)
  • src/routes/admin-audio.js — POST /:id/fedi-open toggle (god-only) + fedi_open in the track query
  • src/views/pages/admin-audio.ejs — per-track share toggle next to the download toggle
  • src/services/i18n.js — aaud.fedi_on/off labels (nl/en/de)

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 16.3 KB
Line 
1import Database from 'better-sqlite3';
2import path from 'path';
3import { fileURLToPath } from 'url';
4import fs from 'fs';
5
6const __dirname = path.dirname(fileURLToPath(import.meta.url));
7const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../storage/database.sqlite');
8
9// Ensure storage directory exists
10const storageDir = path.dirname(dbPath);
11if (!fs.existsSync(storageDir)) {
12 fs.mkdirSync(storageDir, { recursive: true });
13}
14
15// Initialize database
16const db = new Database(dbPath);
17db.pragma('journal_mode = WAL');
18db.pragma('foreign_keys = ON');
19
20export function initializeDatabase() {
21 const tableExists = db.prepare(`
22 SELECT name FROM sqlite_master WHERE type='table' AND name='users'
23 `).get();
24
25 if (!tableExists) {
26 console.log('🔧 Initializing database schema...');
27 const schemaPath = path.join(__dirname, '..', 'db', 'migrations', '001-init.sql');
28 const schema = fs.readFileSync(schemaPath, 'utf-8');
29 db.exec(schema);
30 console.log('✅ Database initialized with v9-soul schema');
31 }
32
33 // Additive column migrations — safe to run every boot.
34 // SQLite throws if the column already exists; we swallow that.
35 ensureColumn('sites', 'enable_audio_player', 'INTEGER DEFAULT 1');
36 ensureColumn('sites', 'profile_photo', 'TEXT');
37 ensureColumn('audio_tracks', 'cover_url', 'TEXT');
38 ensureColumn('audio_tracks', 'album', 'TEXT');
39 ensureColumn('users', 'reset_token', 'TEXT');
40 ensureColumn('users', 'reset_token_expires', 'DATETIME');
41 // Google OAuth: link a Google account to a user (login via Google).
42 ensureColumn('users', 'google_sub', 'TEXT');
43 // Read-only/viewer account: can view everything but make no changes.
44 ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
45 // Personal interface language (nl|en|de). Null = follow the default (site/env/browser).
46 ensureColumn('users', 'lang', 'TEXT');
47 // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
48 // Circles: whether this site may appear in other sites' circles (surfacing opt-out).
49 ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
50
51 // One EXPLICIT primary/main site (= the company/label site in hub mode,
52 // the only site in solo) instead of the fragile "oldest = main" convention
53 // that was duplicated in 4 places. Backfill: mark the oldest if no primary
54 // site exists yet, so existing behaviour is preserved exactly.
55 ensureColumn('sites', 'is_primary', 'INTEGER DEFAULT 0');
56 try {
57 const hasPrimary = db.prepare('SELECT 1 FROM sites WHERE is_primary = 1 LIMIT 1').get();
58 if (!hasPrimary) {
59 const oldest = db.prepare('SELECT id FROM sites ORDER BY created_at ASC LIMIT 1').get();
60 if (oldest) db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(oldest.id);
61 }
62 } catch (e) { /* sites table still empty/absent on fresh init — ensurePrimarySite handles it */ }
63
64 // v9 audit additions —————————————————————————————————————————
65 // SEO/social columns the v9 template uses (most live in 001-init.sql already
66 // for fresh DBs but ensureColumn is idempotent for existing DBs).
67 ensureColumn('sites', 'twitter', 'TEXT'); // @handle (with @)
68 ensureColumn('sites', 'schema_type', "TEXT DEFAULT 'Person'"); // Person|Organization
69 ensureColumn('sites', 'publisher_name', 'TEXT');
70 ensureColumn('sites', 'publisher_url', 'TEXT');
71 ensureColumn('sites', 'publisher_logo', 'TEXT');
72 ensureColumn('sites', 'profile_enabled', 'INTEGER DEFAULT 1');
73 ensureColumn('sites', 'profile_name', 'TEXT'); // display name (falls back to title)
74 ensureColumn('sites', 'profile_bio', 'TEXT'); // short bio for header
75 ensureColumn('sites', 'profile_links', 'TEXT'); // JSON array [{platform, url}]
76 ensureColumn('sites', 'feed_view_default', "TEXT DEFAULT 'grid'"); // timeline | grid
77 ensureColumn('sites', 'feed_view_switch', 'INTEGER DEFAULT 1'); // show switcher
78 ensureColumn('sites', 'show_search', 'INTEGER DEFAULT 1');
79 ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1');
80
81 // Per-post noindex + type
82 ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
83 ensureColumn('posts', 'publish_at', 'DATETIME'); // release planning (premium #3): scheduled go-live
84 ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0'); // fan-only preview (premium #3)
85 ensureColumn('posts', 'nsfw', 'INTEGER DEFAULT 0'); // sensitive content → blur + click-to-reveal; fediverse sensitive
86 ensureColumn('posts', 'content_warning', 'TEXT'); // custom CW label (empty = default "Gevoelige inhoud")
87 ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio
88
89 // Statistics (premium module) — bare counters, cookie-free.
90 ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // views per post
91 ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0'); // plays per track
92 ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-for-email (premium #2)
93 ensureColumn('audio_tracks', 'credit', 'TEXT'); // owner/credit (copyright holder)
94 ensureColumn('audio_tracks', 'license', 'TEXT'); // license (e.g. "CC BY 4.0", "All rights reserved")
95 ensureColumn('audio_tracks', 'link_spotify', 'TEXT'); // "open in" links per track
96 ensureColumn('audio_tracks', 'link_youtube', 'TEXT');
97 ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
98 // Per-track: federate the actual audio file as an AS2 Audio attachment so it plays inline
99 // in EVERY fediverse client (incl. the Mastodon apps). Default 0 = gated (web player only,
100 // file not exposed). Opt-in 1 = the file is served ungated + shared on the fediverse.
101 ensureColumn('audio_tracks', 'fedi_open', 'INTEGER DEFAULT 0');
102
103 // Playlists (v9 feature) — first-class entity. CREATE IF NOT EXISTS is
104 // idempotent so it's safe to run on every boot regardless of DB age.
105 db.exec(`
106 CREATE TABLE IF NOT EXISTS playlists (
107 id TEXT PRIMARY KEY,
108 site_id TEXT NOT NULL,
109 title TEXT NOT NULL,
110 artist TEXT,
111 year INTEGER,
112 cover_url TEXT,
113 kind TEXT DEFAULT 'album',
114 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
115 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
116 FOREIGN KEY (site_id) REFERENCES sites(id)
117 );
118 CREATE TABLE IF NOT EXISTS playlist_tracks (
119 playlist_id TEXT NOT NULL,
120 track_id TEXT NOT NULL,
121 position INTEGER NOT NULL DEFAULT 0,
122 PRIMARY KEY (playlist_id, track_id),
123 FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
124 FOREIGN KEY (track_id) REFERENCES audio_tracks(id) ON DELETE CASCADE
125 );
126 CREATE INDEX IF NOT EXISTS idx_playlist_tracks_pos
127 ON playlist_tracks(playlist_id, position);
128 `);
129
130 // Global app settings (key/value singleton). Includes the tenancy mode
131 // (solo = one site, hub = company site + /user/). Default = solo.
132 db.exec(`
133 CREATE TABLE IF NOT EXISTS app_settings (
134 key TEXT PRIMARY KEY,
135 value TEXT,
136 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
137 );
138 `);
139 db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('tenancy', 'solo')").run();
140
141 // ── Statistics (premium) — cookie-free ──────────────────────
142 // stat_daily: pageview count per day per site (bare counter).
143 // stat_visitor_day: one row per UNIQUE visitor hash per day per site
144 // (sha256 of IP+UA+day-salt; the salt rotates daily and is never stored
145 // → no persistent identifier, no cookie, no consent required).
146 db.exec(`
147 CREATE TABLE IF NOT EXISTS stat_daily (
148 site_id TEXT NOT NULL,
149 day TEXT NOT NULL,
150 pageviews INTEGER NOT NULL DEFAULT 0,
151 PRIMARY KEY (site_id, day)
152 );
153 CREATE TABLE IF NOT EXISTS stat_visitor_day (
154 site_id TEXT NOT NULL,
155 day TEXT NOT NULL,
156 visitor_hash TEXT NOT NULL,
157 PRIMARY KEY (site_id, day, visitor_hash)
158 );
159 CREATE INDEX IF NOT EXISTS idx_stat_visitor_day ON stat_visitor_day(site_id, day);
160 CREATE TABLE IF NOT EXISTS stat_referrer (
161 site_id TEXT NOT NULL,
162 host TEXT NOT NULL,
163 count INTEGER NOT NULL DEFAULT 0,
164 PRIMARY KEY (site_id, host)
165 );
166 `);
167
168 // Newsletter / mailing list (premium). Subscribers per site; double opt-in when SMTP
169 // is configured (status 'pending' until confirmed), otherwise single opt-in ('confirmed').
170 // 'unsub' = unsubscribed. token = confirm/unsubscribe key (used in email links).
171 db.exec(`
172 CREATE TABLE IF NOT EXISTS subscribers (
173 id TEXT PRIMARY KEY,
174 site_id TEXT NOT NULL,
175 email TEXT NOT NULL,
176 status TEXT NOT NULL DEFAULT 'pending',
177 source TEXT DEFAULT 'widget',
178 token TEXT NOT NULL,
179 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
180 confirmed_at DATETIME,
181 UNIQUE(site_id, email)
182 );
183 CREATE INDEX IF NOT EXISTS idx_subscribers_site_status ON subscribers(site_id, status);
184 `);
185
186 // Sent newsletters (history + counts).
187 db.exec(`
188 CREATE TABLE IF NOT EXISTS newsletters (
189 id TEXT PRIMARY KEY,
190 site_id TEXT NOT NULL,
191 subject TEXT NOT NULL,
192 body TEXT NOT NULL,
193 sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
194 recipient_count INTEGER DEFAULT 0
195 );
196 `);
197
198 // Show agenda (premium #8): tour dates / gigs per site.
199 db.exec(`
200 CREATE TABLE IF NOT EXISTS shows (
201 id TEXT PRIMARY KEY,
202 site_id TEXT NOT NULL,
203 date TEXT NOT NULL,
204 time TEXT,
205 city TEXT NOT NULL,
206 venue TEXT,
207 country TEXT,
208 ticket_url TEXT,
209 notes TEXT,
210 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
211 );
212 CREATE INDEX IF NOT EXISTS idx_shows_site_date ON shows(site_id, date);
213 `);
214
215 // Link-in-bio click statistics (premium #6). One counter per (site, url); the
216 // link-in-bio page links via /links/go/:i which counts the click and redirects.
217 db.exec(`
218 CREATE TABLE IF NOT EXISTS link_clicks (
219 site_id TEXT NOT NULL,
220 url TEXT NOT NULL,
221 clicks INTEGER DEFAULT 0,
222 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
223 PRIMARY KEY (site_id, url)
224 );
225 `);
226
227
228 // ── ActivityPub (fediverse bridge) ──────────────────────────
229 // RSA keypair per actor (Mastodon-compatible HTTP Signatures; separate from
230 // the Cirkels Ed25519 keys). ap_followers = remote AP actors following us.
231 db.exec(`
232 CREATE TABLE IF NOT EXISTS ap_keys (
233 slug TEXT PRIMARY KEY,
234 public_pem TEXT NOT NULL,
235 private_pem TEXT NOT NULL,
236 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
237 );
238 CREATE TABLE IF NOT EXISTS ap_followers (
239 id INTEGER PRIMARY KEY AUTOINCREMENT,
240 slug TEXT NOT NULL,
241 actor_uri TEXT NOT NULL,
242 inbox TEXT,
243 shared_inbox TEXT,
244 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
245 UNIQUE(slug, actor_uri)
246 );
247 CREATE INDEX IF NOT EXISTS idx_ap_followers_slug ON ap_followers(slug);
248 CREATE TABLE IF NOT EXISTS ap_interactions (
249 id INTEGER PRIMARY KEY AUTOINCREMENT,
250 kind TEXT NOT NULL, -- 'reply' | 'like' | 'announce'
251 post_id TEXT NOT NULL,
252 object_uri TEXT NOT NULL DEFAULT '', -- remote note id (reply) or '' (like/announce)
253 actor_uri TEXT NOT NULL,
254 actor_name TEXT,
255 actor_handle TEXT,
256 actor_url TEXT,
257 actor_icon TEXT,
258 content TEXT, -- sanitized HTML (reply)
259 published TEXT,
260 parent_uri TEXT, -- the note this reply replies to (for nesting)
261 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
262 UNIQUE(kind, post_id, actor_uri, object_uri)
263 );
264 CREATE INDEX IF NOT EXISTS idx_ap_inter_post ON ap_interactions(post_id, kind);
265 CREATE TABLE IF NOT EXISTS ap_outbox (
266 id TEXT PRIMARY KEY, -- note path segment (uuid) → /ap/notes/<id>
267 site_slug TEXT NOT NULL,
268 post_id TEXT NOT NULL,
269 post_slug TEXT,
270 in_reply_to TEXT, -- remote status uri we reply to
271 to_actor TEXT, -- remote actor uri (mentioned)
272 to_handle TEXT,
273 content TEXT NOT NULL, -- sanitized HTML of our reply
274 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
275 );
276 CREATE INDEX IF NOT EXISTS idx_ap_outbox_post ON ap_outbox(post_id);
277 -- Your like/boost state on a REMOTE post (the interact page), so those become toggles.
278 CREATE TABLE IF NOT EXISTS ap_my_reactions (
279 site_slug TEXT NOT NULL,
280 target_uri TEXT NOT NULL,
281 kind TEXT NOT NULL, -- 'like' | 'boost'
282 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
283 UNIQUE(site_slug, target_uri, kind)
284 );
285 `);
286 ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs)
287 ensureColumn('ap_interactions', 'acted_boost', 'INTEGER DEFAULT 0'); // owner boosted this comment (🔁) → can undo
288 ensureColumn('ap_interactions', 'acted_like', 'INTEGER DEFAULT 0'); // owner liked this comment (⭐) → can undo
289
290 // Fediverse CLIENT: accounts WE follow (outbound) + the home timeline of their posts.
291 db.exec(`
292 CREATE TABLE IF NOT EXISTS ap_following (
293 id INTEGER PRIMARY KEY AUTOINCREMENT,
294 slug TEXT NOT NULL, -- our site that follows
295 actor_uri TEXT NOT NULL, -- the followed account's actor id
296 handle TEXT, name TEXT, icon TEXT, url TEXT,
297 inbox TEXT, -- their inbox (for Create delivery / Undo)
298 follow_id TEXT, -- the Follow activity id we sent (Accept matching)
299 status TEXT DEFAULT 'pending', -- pending | accepted
300 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
301 UNIQUE(slug, actor_uri)
302 );
303 CREATE TABLE IF NOT EXISTS ap_timeline (
304 id TEXT NOT NULL, -- the remote note's AP id
305 slug TEXT NOT NULL, -- whose home timeline (our site)
306 author_uri TEXT, author_name TEXT, author_handle TEXT, author_icon TEXT, author_url TEXT,
307 content TEXT, url TEXT, published TEXT, media_json TEXT,
308 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
309 UNIQUE(slug, id)
310 );
311 CREATE INDEX IF NOT EXISTS idx_ap_timeline_slug ON ap_timeline(slug, published);
312 CREATE TABLE IF NOT EXISTS ap_blocks (
313 id INTEGER PRIMARY KEY AUTOINCREMENT,
314 slug TEXT NOT NULL, -- our site that set the block
315 target TEXT NOT NULL, -- actor URI (actor block) or domain (domain block)
316 kind TEXT NOT NULL, -- 'actor' | 'domain'
317 label TEXT, -- display (@handle or domain)
318 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
319 UNIQUE(slug, target)
320 );
321 CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
322 CREATE TABLE IF NOT EXISTS ap_delivery (
323 id INTEGER PRIMARY KEY AUTOINCREMENT,
324 slug TEXT NOT NULL, -- our site/actor that signs the delivery
325 inbox TEXT NOT NULL, -- recipient inbox URL
326 body TEXT NOT NULL, -- the activity JSON to POST
327 attempts INTEGER NOT NULL DEFAULT 0,
328 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
329 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
330 );
331 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
332 `);
333 // "Feature" a followed account: its posts show in the local Cirkel.
334 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
335 // A timeline post you boosted (🔁) — also shown in the Cirkel (mixed by date).
336 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
337 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (⭐) → toggle
338 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post → blur in the Cirkel
339 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
340 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this → "X boosted"
341 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
342 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
343}
344
345function ensureColumn(table, column, definition) {
346 try {
347 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
348 console.log(`🔧 Added column ${table}.${column}`);
349 } catch (e) {
350 // "duplicate column name" → already there. Anything else, surface it.
351 if (!/duplicate column/i.test(e.message)) {
352 console.error(`❌ ensureColumn(${table}.${column}):`, e.message);
353 }
354 }
355}
356
357export default db;
Note: See TracBrowser for help on using the repository browser.