at main 9.4 kB view raw
1import { readFileSync } from 'node:fs'; 2import Database from 'better-sqlite3'; 3 4const SUBS_PER_ACCOUNT_LIMIT = 5; 5const SECONDARY_FILTERS_LIMIT = 100; 6 7const SCHEMA_FNAME = './schema.sql'; 8 9export class DB { 10 #stmt_insert_account; 11 #stmt_get_account; 12 #stmt_delete_account; 13 #stmt_insert_push_sub; 14 #stmt_get_all_sub_dids; 15 #stmt_get_push_subs; 16 #stmt_get_push_sub; 17 #stmt_update_push_sub; 18 #stmt_delete_push_sub; 19 #stmt_get_push_info; 20 #stmt_set_role; 21 #stmt_get_notify_account_globals; 22 #stmt_set_notify_account_globals; 23 #stmt_set_notification_filter; 24 #stmt_get_notification_filter; 25 #stmt_count_notification_filters; 26 #stmt_rm_notification_filter; 27 28 #stmt_admin_add_secret; 29 #stmt_admin_expire_secret; 30 #stmt_admin_get_secrets; 31 #stmt_admin_secret_accounts; 32 #stmt_admin_nonsecret_accounts; 33 34 #transactionally; 35 #db; 36 37 constructor(filename, init = false, handleExit = true) { 38 const db = new Database(filename); 39 40 db.pragma('journal_mode = WAL'); 41 db.pragma('foreign_keys = ON'); 42 43 if (init) { 44 const createSchema = readFileSync(SCHEMA_FNAME, 'utf8'); 45 db.exec(createSchema); 46 } 47 48 if (handleExit) { // probably a better way to do this 🤷‍♀️ 49 process.on('exit', () => db.close()); 50 process.on('SIGHUP', () => process.exit(128 + 1)); 51 process.on('SIGINT', () => process.exit(128 + 2)); 52 process.on('SIGTERM', () => process.exit(128 + 15)); 53 } 54 55 this.#stmt_insert_account = db.prepare( 56 `insert into accounts (did) 57 values (?) 58 on conflict do nothing`); 59 60 this.#stmt_get_account = db.prepare( 61 `select a.first_seen, 62 a.role, 63 count(*) as total_subs 64 from accounts a 65 left outer join push_subs p on (p.account_did = a.did) 66 where a.did = ? 67 group by a.did`); 68 69 this.#stmt_delete_account = db.prepare( 70 `delete from accounts 71 where did = ?`); 72 73 this.#stmt_insert_push_sub = db.prepare( 74 `insert into push_subs (account_did, session, subscription) 75 values (?, ?, ?) 76 on conflict do update 77 set subscription = excluded.subscription`); 78 79 this.#stmt_get_all_sub_dids = db.prepare( 80 `select distinct account_did 81 from push_subs`); 82 83 this.#stmt_get_push_subs = db.prepare( 84 `select session, 85 subscription, 86 (julianday(CURRENT_TIMESTAMP) - julianday(last_push)) * 24 * 60 * 60 87 as 'since_last_push' 88 from push_subs 89 where account_did = ?`); 90 91 this.#stmt_get_push_sub = db.prepare( 92 `select session, 93 subscription, 94 (julianday(CURRENT_TIMESTAMP) - julianday(last_push)) * 24 * 60 * 60 95 as 'since_last_push' 96 from push_subs 97 where session = ?`); 98 99 this.#stmt_update_push_sub = db.prepare( 100 `update push_subs 101 set last_push = CURRENT_TIMESTAMP, 102 total_pushes = total_pushes + 1 103 where session = ?`); 104 105 this.#stmt_delete_push_sub = db.prepare( 106 `delete from push_subs 107 where session = ?`); 108 109 this.#stmt_get_push_info = db.prepare( 110 `select created, 111 last_push, 112 total_pushes 113 from push_subs 114 where account_did = ?`); 115 116 this.#stmt_set_role = db.prepare( 117 `update accounts 118 set role = :role, 119 secret_password = :secret_password 120 where did = :did 121 and :secret_password in (select password 122 from top_secret_passwords)`); 123 124 this.#stmt_get_notify_account_globals = db.prepare( 125 `select notify_enabled, 126 notify_self 127 from accounts 128 where did = :did`); 129 130 this.#stmt_set_notify_account_globals = db.prepare( 131 `update accounts 132 set notify_enabled = :notify_enabled, 133 notify_self = :notify_self 134 where did = :did`); 135 136 this.#stmt_set_notification_filter = db.prepare( 137 `insert into notification_filters (account_did, selector, selection, notify) 138 values (:did, :selector, :selection, :notify) 139 on conflict do update 140 set notify = excluded.notify`); 141 142 this.#stmt_get_notification_filter = db.prepare( 143 `select notify 144 from notification_filters 145 where account_did = :did 146 and selector = :selector 147 and selection = :selection`); 148 149 this.#stmt_count_notification_filters = db.prepare( 150 `select count(*) as n 151 from notification_filters 152 where account_did = :did`); 153 154 this.#stmt_rm_notification_filter = db.prepare( 155 `delete from notification_filters 156 where account_did = :did 157 and selector = :selector 158 and selection = :selection`); 159 160 161 this.#stmt_admin_add_secret = db.prepare( 162 `insert into top_secret_passwords (password) 163 values (?)`); 164 165 this.#stmt_admin_expire_secret = db.prepare( 166 `update top_secret_passwords 167 set expired = CURRENT_TIMESTAMP 168 where expired is null 169 and password = ?`); 170 171 this.#stmt_admin_get_secrets = db.prepare( 172 `select password, 173 unixepoch(added) * 1000 as 'added', 174 unixepoch(expired) * 1000 as 'expired' 175 from top_secret_passwords 176 order by expired, added desc`); 177 178 this.#stmt_admin_secret_accounts = db.prepare( 179 `select did, 180 unixepoch(first_seen) * 1000 as 'first_seen', 181 role, 182 count(*) as 'active_subs', 183 sum(p.total_pushes) as 'total_pushes', 184 unixepoch(max(p.last_push)) * 1000 as 'last_push' 185 from accounts 186 left outer join push_subs p on (p.account_did = did) 187 where secret_password = :password 188 group by did 189 order by first_seen desc`); 190 191 this.#stmt_admin_nonsecret_accounts = db.prepare( 192 `select did, 193 unixepoch(first_seen) * 1000 as 'first_seen', 194 role, 195 count(*) as 'active_subs', 196 sum(p.total_pushes) as 'total_pushes', 197 unixepoch(max(p.last_push)) * 1000 as 'last_push' 198 from accounts 199 left outer join push_subs p on (p.account_did = did) 200 left outer join top_secret_passwords s on (s.password = secret_password) 201 where s.password is null 202 group by did 203 order by first_seen desc`); 204 205 this.#transactionally = t => db.transaction(t).immediate(); 206 } 207 208 addAccount(did) { 209 this.#stmt_insert_account.run(did); 210 } 211 212 getAccount(did) { 213 return this.#stmt_get_account.get(did); 214 } 215 216 addPushSub(did, session, sub) { 217 this.#transactionally(() => { 218 const res = this.#stmt_get_account.get(did); 219 if (!res) { 220 throw new Error(`Could not find account for ${did}`); 221 } 222 if (res.total_subs >= SUBS_PER_ACCOUNT_LIMIT) { 223 throw new Error(`Too many subscriptions for ${did}`); 224 } 225 this.#stmt_insert_push_sub.run(did, session, sub); 226 }); 227 } 228 229 getSubscribedDids() { 230 return this.#stmt_get_all_sub_dids.all().map(r => r.account_did); 231 } 232 233 getSubsByDid(did) { 234 return this.#stmt_get_push_subs.all(did); 235 } 236 237 getSubBySession(session) { 238 return this.#stmt_get_push_sub.get(session); 239 } 240 241 updateLastPush(session) { 242 this.#stmt_update_push_sub.run(session); 243 } 244 245 deleteSub(session) { 246 this.#stmt_delete_push_sub.run(session); 247 } 248 249 setRole(params) { 250 let res = this.#stmt_set_role.run(params); 251 return res.changes > 0; 252 } 253 254 getNotifyAccountGlobals(did) { 255 return this.#stmt_get_notify_account_globals.get({ did }); 256 } 257 258 setNotifyAccountGlobals(did, globals) { 259 this.#transactionally(() => { 260 const update = this.getNotifyAccountGlobals(did); 261 if (globals.notify_enabled !== undefined) update.notify_enabled = +globals.notify_enabled; 262 if (globals.notify_self !== undefined) update.notify_self = +globals.notify_self; 263 update.did = did; 264 this.#stmt_set_notify_account_globals.run(update); 265 }); 266 } 267 268 getNotificationFilter(did, selector, selection) { 269 const res = this.#stmt_get_notification_filter.get({ did, selector, selection }); 270 const dbNotify = res?.notify; 271 if (dbNotify === 1) return true; 272 else if (dbNotify === 0) return false; 273 else return null; 274 } 275 276 setNotificationFilter(did, selector, selection, notify) { 277 if (notify === null) { 278 this.#stmt_rm_notification_filter.run({ did, selector, selection }); 279 } else { 280 this.#transactionally(() => { 281 const { n } = this.#stmt_count_notification_filters.get({ did }); 282 if (n >= SECONDARY_FILTERS_LIMIT) { 283 throw new Error('max filters set for account'); 284 } 285 let dbNotify = null; 286 if (notify === true) dbNotify = 1; 287 else if (notify === false) dbNotify = 0; 288 this.#stmt_set_notification_filter.run({ did, selector, selection, notify: dbNotify }); 289 }); 290 } 291 } 292 293 294 addTopSecret(secretPassword) { 295 this.#stmt_admin_add_secret.run(secretPassword); 296 } 297 298 expireTopSecret(secretPassword) { 299 let res = this.#stmt_admin_expire_secret.run(secretPassword); 300 return res.changes > 0; 301 } 302 303 getSecrets() { 304 return this.#stmt_admin_get_secrets.all(); 305 } 306 307 getSecretAccounts(secretPassword) { 308 return this.#stmt_admin_secret_accounts.all({ password: secretPassword }); 309 } 310 311 getNonSecretAccounts() { 312 return this.#stmt_admin_nonsecret_accounts.all(); 313 } 314}