source: Klonkt/src/config/database.js@ 1d6f9a2

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

feat(video-cover): backend — convert animated WebP covers to a muted loop MP4 on upload

  • DB: posts.cover_video_url (the muted loop MP4 for an animated cover)
  • /posts/upload-image: an animated WebP → VideoCoverService.animatedWebpToVideo → returns {video} next to the still {url}; create + save persist cover_video_url (req.body.cover_video_url)

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

  • Property mode set to 100644
File size: 16.5 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', 'cover_video_url', 'TEXT'); // muted loop MP4 for an animated cover (Safari-smooth)
87 ensureColumn('posts', 'content_warning', 'TEXT'); // custom CW label (empty = default "Gevoelige inhoud")
88 ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio
89
90 // Statistics (premium module) — bare counters, cookie-free.
91 ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // views per post
92 ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0'); // plays per track
93 ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-for-email (premium #2)
94 ensureColumn('audio_tracks', 'credit', 'TEXT'); // owner/credit (copyright holder)
95 ensureColumn('audio_tracks', 'license', 'TEXT'); // license (e.g. "CC BY 4.0", "All rights reserved")
96 ensureColumn('audio_tracks', 'link_spotify', 'TEXT'); // "open in" links per track
97 ensureColumn('audio_tracks', 'link_youtube', 'TEXT');
98 ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
99 // Per-track: federate the actual audio file as an AS2 Audio attachment so it plays inline
100 // in EVERY fediverse client (incl. the Mastodon apps). Default 0 = gated (web player only,
101 // file not exposed). Opt-in 1 = the file is served ungated + shared on the fediverse.
102 ensureColumn('audio_tracks', 'fedi_open', 'INTEGER DEFAULT 0');
103
104 // Playlists (v9 feature) — first-class entity. CREATE IF NOT EXISTS is
105 // idempotent so it's safe to run on every boot regardless of DB age.
106 db.exec(`
107 CREATE TABLE IF NOT EXISTS playlists (
108 id TEXT PRIMARY KEY,
109 site_id TEXT NOT NULL,
110 title TEXT NOT NULL,
111 artist TEXT,
112 year INTEGER,
113 cover_url TEXT,
114 kind TEXT DEFAULT 'album',
115 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
116 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
117 FOREIGN KEY (site_id) REFERENCES sites(id)
118 );
119 CREATE TABLE IF NOT EXISTS playlist_tracks (
120 playlist_id TEXT NOT NULL,
121 track_id TEXT NOT NULL,
122 position INTEGER NOT NULL DEFAULT 0,
123 PRIMARY KEY (playlist_id, track_id),
124 FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
125 FOREIGN KEY (track_id) REFERENCES audio_tracks(id) ON DELETE CASCADE
126 );
127 CREATE INDEX IF NOT EXISTS idx_playlist_tracks_pos
128 ON playlist_tracks(playlist_id, position);
129 `);
130
131 // Global app settings (key/value singleton). Includes the tenancy mode
132 // (solo = one site, hub = company site + /user/). Default = solo.
133 db.exec(`
134 CREATE TABLE IF NOT EXISTS app_settings (
135 key TEXT PRIMARY KEY,
136 value TEXT,
137 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
138 );
139 `);
140 db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('tenancy', 'solo')").run();
141
142 // ── Statistics (premium) — cookie-free ──────────────────────
143 // stat_daily: pageview count per day per site (bare counter).
144 // stat_visitor_day: one row per UNIQUE visitor hash per day per site
145 // (sha256 of IP+UA+day-salt; the salt rotates daily and is never stored
146 // → no persistent identifier, no cookie, no consent required).
147 db.exec(`
148 CREATE TABLE IF NOT EXISTS stat_daily (
149 site_id TEXT NOT NULL,
150 day TEXT NOT NULL,
151 pageviews INTEGER NOT NULL DEFAULT 0,
152 PRIMARY KEY (site_id, day)
153 );
154 CREATE TABLE IF NOT EXISTS stat_visitor_day (
155 site_id TEXT NOT NULL,
156 day TEXT NOT NULL,
157 visitor_hash TEXT NOT NULL,
158 PRIMARY KEY (site_id, day, visitor_hash)
159 );
160 CREATE INDEX IF NOT EXISTS idx_stat_visitor_day ON stat_visitor_day(site_id, day);
161 CREATE TABLE IF NOT EXISTS stat_referrer (
162 site_id TEXT NOT NULL,
163 host TEXT NOT NULL,
164 count INTEGER NOT NULL DEFAULT 0,
165 PRIMARY KEY (site_id, host)
166 );
167 `);
168
169 // Newsletter / mailing list (premium). Subscribers per site; double opt-in when SMTP
170 // is configured (status 'pending' until confirmed), otherwise single opt-in ('confirmed').
171 // 'unsub' = unsubscribed. token = confirm/unsubscribe key (used in email links).
172 db.exec(`
173 CREATE TABLE IF NOT EXISTS subscribers (
174 id TEXT PRIMARY KEY,
175 site_id TEXT NOT NULL,
176 email TEXT NOT NULL,
177 status TEXT NOT NULL DEFAULT 'pending',
178 source TEXT DEFAULT 'widget',
179 token TEXT NOT NULL,
180 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
181 confirmed_at DATETIME,
182 UNIQUE(site_id, email)
183 );
184 CREATE INDEX IF NOT EXISTS idx_subscribers_site_status ON subscribers(site_id, status);
185 `);
186
187 // Sent newsletters (history + counts).
188 db.exec(`
189 CREATE TABLE IF NOT EXISTS newsletters (
190 id TEXT PRIMARY KEY,
191 site_id TEXT NOT NULL,
192 subject TEXT NOT NULL,
193 body TEXT NOT NULL,
194 sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
195 recipient_count INTEGER DEFAULT 0
196 );
197 `);
198
199 // Show agenda (premium #8): tour dates / gigs per site.
200 db.exec(`
201 CREATE TABLE IF NOT EXISTS shows (
202 id TEXT PRIMARY KEY,
203 site_id TEXT NOT NULL,
204 date TEXT NOT NULL,
205 time TEXT,
206 city TEXT NOT NULL,
207 venue TEXT,
208 country TEXT,
209 ticket_url TEXT,
210 notes TEXT,
211 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
212 );
213 CREATE INDEX IF NOT EXISTS idx_shows_site_date ON shows(site_id, date);
214 `);
215
216 // Link-in-bio click statistics (premium #6). One counter per (site, url); the
217 // link-in-bio page links via /links/go/:i which counts the click and redirects.
218 db.exec(`
219 CREATE TABLE IF NOT EXISTS link_clicks (
220 site_id TEXT NOT NULL,
221 url TEXT NOT NULL,
222 clicks INTEGER DEFAULT 0,
223 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
224 PRIMARY KEY (site_id, url)
225 );
226 `);
227
228
229 // ── ActivityPub (fediverse bridge) ──────────────────────────
230 // RSA keypair per actor (Mastodon-compatible HTTP Signatures; separate from
231 // the Cirkels Ed25519 keys). ap_followers = remote AP actors following us.
232 db.exec(`
233 CREATE TABLE IF NOT EXISTS ap_keys (
234 slug TEXT PRIMARY KEY,
235 public_pem TEXT NOT NULL,
236 private_pem TEXT NOT NULL,
237 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
238 );
239 CREATE TABLE IF NOT EXISTS ap_followers (
240 id INTEGER PRIMARY KEY AUTOINCREMENT,
241 slug TEXT NOT NULL,
242 actor_uri TEXT NOT NULL,
243 inbox TEXT,
244 shared_inbox TEXT,
245 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
246 UNIQUE(slug, actor_uri)
247 );
248 CREATE INDEX IF NOT EXISTS idx_ap_followers_slug ON ap_followers(slug);
249 CREATE TABLE IF NOT EXISTS ap_interactions (
250 id INTEGER PRIMARY KEY AUTOINCREMENT,
251 kind TEXT NOT NULL, -- 'reply' | 'like' | 'announce'
252 post_id TEXT NOT NULL,
253 object_uri TEXT NOT NULL DEFAULT '', -- remote note id (reply) or '' (like/announce)
254 actor_uri TEXT NOT NULL,
255 actor_name TEXT,
256 actor_handle TEXT,
257 actor_url TEXT,
258 actor_icon TEXT,
259 content TEXT, -- sanitized HTML (reply)
260 published TEXT,
261 parent_uri TEXT, -- the note this reply replies to (for nesting)
262 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
263 UNIQUE(kind, post_id, actor_uri, object_uri)
264 );
265 CREATE INDEX IF NOT EXISTS idx_ap_inter_post ON ap_interactions(post_id, kind);
266 CREATE TABLE IF NOT EXISTS ap_outbox (
267 id TEXT PRIMARY KEY, -- note path segment (uuid) → /ap/notes/<id>
268 site_slug TEXT NOT NULL,
269 post_id TEXT NOT NULL,
270 post_slug TEXT,
271 in_reply_to TEXT, -- remote status uri we reply to
272 to_actor TEXT, -- remote actor uri (mentioned)
273 to_handle TEXT,
274 content TEXT NOT NULL, -- sanitized HTML of our reply
275 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
276 );
277 CREATE INDEX IF NOT EXISTS idx_ap_outbox_post ON ap_outbox(post_id);
278 -- Your like/boost state on a REMOTE post (the interact page), so those become toggles.
279 CREATE TABLE IF NOT EXISTS ap_my_reactions (
280 site_slug TEXT NOT NULL,
281 target_uri TEXT NOT NULL,
282 kind TEXT NOT NULL, -- 'like' | 'boost'
283 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
284 UNIQUE(site_slug, target_uri, kind)
285 );
286 `);
287 ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs)
288 ensureColumn('ap_interactions', 'acted_boost', 'INTEGER DEFAULT 0'); // owner boosted this comment (🔁) → can undo
289 ensureColumn('ap_interactions', 'acted_like', 'INTEGER DEFAULT 0'); // owner liked this comment (⭐) → can undo
290
291 // Fediverse CLIENT: accounts WE follow (outbound) + the home timeline of their posts.
292 db.exec(`
293 CREATE TABLE IF NOT EXISTS ap_following (
294 id INTEGER PRIMARY KEY AUTOINCREMENT,
295 slug TEXT NOT NULL, -- our site that follows
296 actor_uri TEXT NOT NULL, -- the followed account's actor id
297 handle TEXT, name TEXT, icon TEXT, url TEXT,
298 inbox TEXT, -- their inbox (for Create delivery / Undo)
299 follow_id TEXT, -- the Follow activity id we sent (Accept matching)
300 status TEXT DEFAULT 'pending', -- pending | accepted
301 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
302 UNIQUE(slug, actor_uri)
303 );
304 CREATE TABLE IF NOT EXISTS ap_timeline (
305 id TEXT NOT NULL, -- the remote note's AP id
306 slug TEXT NOT NULL, -- whose home timeline (our site)
307 author_uri TEXT, author_name TEXT, author_handle TEXT, author_icon TEXT, author_url TEXT,
308 content TEXT, url TEXT, published TEXT, media_json TEXT,
309 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
310 UNIQUE(slug, id)
311 );
312 CREATE INDEX IF NOT EXISTS idx_ap_timeline_slug ON ap_timeline(slug, published);
313 CREATE TABLE IF NOT EXISTS ap_blocks (
314 id INTEGER PRIMARY KEY AUTOINCREMENT,
315 slug TEXT NOT NULL, -- our site that set the block
316 target TEXT NOT NULL, -- actor URI (actor block) or domain (domain block)
317 kind TEXT NOT NULL, -- 'actor' | 'domain'
318 label TEXT, -- display (@handle or domain)
319 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
320 UNIQUE(slug, target)
321 );
322 CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
323 CREATE TABLE IF NOT EXISTS ap_delivery (
324 id INTEGER PRIMARY KEY AUTOINCREMENT,
325 slug TEXT NOT NULL, -- our site/actor that signs the delivery
326 inbox TEXT NOT NULL, -- recipient inbox URL
327 body TEXT NOT NULL, -- the activity JSON to POST
328 attempts INTEGER NOT NULL DEFAULT 0,
329 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
330 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
331 );
332 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
333 `);
334 // "Feature" a followed account: its posts show in the local Cirkel.
335 ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
336 // A timeline post you boosted (🔁) — also shown in the Cirkel (mixed by date).
337 ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
338 ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (⭐) → toggle
339 ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0'); // remote sensitive post → blur in the Cirkel
340 ensureColumn('ap_timeline', 'cw', 'TEXT'); // remote content-warning text
341 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this → "X boosted"
342 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle
343 ensureColumn('ap_timeline', 'reblog_icon', 'TEXT'); // the booster's avatar
344}
345
346function ensureColumn(table, column, definition) {
347 try {
348 db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
349 console.log(`🔧 Added column ${table}.${column}`);
350 } catch (e) {
351 // "duplicate column name" → already there. Anything else, surface it.
352 if (!/duplicate column/i.test(e.message)) {
353 console.error(`❌ ensureColumn(${table}.${column}):`, e.message);
354 }
355 }
356}
357
358export default db;
Note: See TracBrowser for help on using the repository browser.