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

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

feat(fediverse): host your own polls (federate as AS2 Question)

A post can carry a poll that federates as an AS2 Question so remote (Mastodon)
followers vote from their own app; votes are tallied server-side and the fresh
counts are pushed back as Update(Question). Voting is fediverse-only; the site
shows live, read-only results. Complements the existing inbound poll support.

  • src/config/database.js — posts.poll_json (our poll definition) + poll_votes table (post_id, actor_uri, choice; UNIQUE) backing the tally + per-actor dedupe.
  • src/services/ActivityPubService.js — parseOwnPoll/pollTally/ownPollView helpers; buildNote emits a Question (oneOf/anyOf + replies.totalItems + endTime/closed + votersCount) for a poll post; handleInbox records a ballot (Note with name + inReplyTo our poll) before the reply path, deduped per actor; a debounced Update(Question) pushes fresh counts to followers; votersCount added to AP_CONTEXT.
  • src/services/Scheduler.js — closeExpiredPolls() marks a poll closed once its endTime passes and pushes the final tally; runs on the existing 60s tick.
  • src/routes/posts.js — parsePollForm() turns the editor fields into poll_json on create/save (a poll with votes is frozen), passes poll_json to the federation hooks, and hands the post page a render-ready ownPollView.
  • src/views/pages/post-edit.ejs — poll section (options, multiple-choice, duration); disabled once the poll has votes.
  • src/views/pages/post.ejs — display-only poll with result bars + voter/close meta.
  • src/services/i18n.js — poll.* + pedit.poll_* strings (nl/en/de).
  • test/polls.test.js — Question shape, tally, percentages, closed state, AS2 term.
  • CHANGELOG(.nl/.de).md — "Create your own polls" under Unreleased.

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

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