source: Klonkt/src/config/database.js@ 328d837

main
Last change on this file since 328d837 was c6cdce6, checked in by Robin Genis <roboburr@…>, 2 months ago

feat(news): show boosts from followed accounts in the News feed

An Announce (boost) from an account you follow, of a REMOTE post, was dropped — only Creates
and boosts of YOUR own posts were handled. Now such a boost resolves the boosted note and
stores it in ap_timeline (marked with who boosted it) so it appears in News with a 'X boosted'
label. We only store it for display and NEVER auto-Announce it onward (anti-feedback-loop);
published = now so it surfaces as fresh activity.

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