Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import postgres from 'postgres'; 2import { createHash } from 'crypto'; 3import type { DomainLookup, CustomDomainLookup } from '@wisp/database'; 4 5// Global cache-only mode flag (set by index.ts) 6let cacheOnlyMode = false; 7 8export function setCacheOnlyMode(enabled: boolean) { 9 cacheOnlyMode = enabled; 10 if (enabled) { 11 console.log('[DB] Cache-only mode enabled - database writes will be skipped'); 12 } 13} 14 15const sql = postgres( 16 process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp', 17 { 18 max: 10, 19 idle_timeout: 20, 20 } 21); 22 23// Domain lookup cache with TTL 24const DOMAIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 25 26interface CachedDomain<T> { 27 value: T; 28 timestamp: number; 29} 30 31const domainCache = new Map<string, CachedDomain<DomainLookup | null>>(); 32const customDomainCache = new Map<string, CachedDomain<CustomDomainLookup | null>>(); 33 34let cleanupInterval: NodeJS.Timeout | null = null; 35 36export function startDomainCacheCleanup() { 37 if (cleanupInterval) return; 38 39 cleanupInterval = setInterval(() => { 40 const now = Date.now(); 41 42 for (const [key, entry] of domainCache.entries()) { 43 if (now - entry.timestamp > DOMAIN_CACHE_TTL) { 44 domainCache.delete(key); 45 } 46 } 47 48 for (const [key, entry] of customDomainCache.entries()) { 49 if (now - entry.timestamp > DOMAIN_CACHE_TTL) { 50 customDomainCache.delete(key); 51 } 52 } 53 }, 30 * 60 * 1000); // Run every 30 minutes 54} 55 56export function stopDomainCacheCleanup() { 57 if (cleanupInterval) { 58 clearInterval(cleanupInterval); 59 cleanupInterval = null; 60 } 61} 62 63export async function getWispDomain(domain: string): Promise<DomainLookup | null> { 64 const key = domain.toLowerCase(); 65 66 // Check cache first 67 const cached = domainCache.get(key); 68 if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { 69 return cached.value; 70 } 71 72 // Query database 73 const result = await sql<DomainLookup[]>` 74 SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1 75 `; 76 const data = result[0] || null; 77 78 // Cache the result 79 domainCache.set(key, { value: data, timestamp: Date.now() }); 80 81 return data; 82} 83 84export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> { 85 const key = domain.toLowerCase(); 86 87 // Check cache first 88 const cached = customDomainCache.get(key); 89 if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { 90 return cached.value; 91 } 92 93 // Query database 94 const result = await sql<CustomDomainLookup[]>` 95 SELECT id, domain, did, rkey, verified FROM custom_domains 96 WHERE domain = ${key} AND verified = true LIMIT 1 97 `; 98 const data = result[0] || null; 99 100 // Cache the result 101 customDomainCache.set(key, { value: data, timestamp: Date.now() }); 102 103 return data; 104} 105 106export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> { 107 const key = `hash:${hash}`; 108 109 // Check cache first 110 const cached = customDomainCache.get(key); 111 if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { 112 return cached.value; 113 } 114 115 // Query database 116 const result = await sql<CustomDomainLookup[]>` 117 SELECT id, domain, did, rkey, verified FROM custom_domains 118 WHERE id = ${hash} AND verified = true LIMIT 1 119 `; 120 const data = result[0] || null; 121 122 // Cache the result 123 customDomainCache.set(key, { value: data, timestamp: Date.now() }); 124 125 return data; 126} 127 128export async function upsertSite(did: string, rkey: string, displayName?: string) { 129 // Skip database writes in cache-only mode 130 if (cacheOnlyMode) { 131 console.log('[DB] Skipping upsertSite (cache-only mode)', { did, rkey }); 132 return; 133 } 134 135 try { 136 // Only set display_name if provided (not undefined/null/empty) 137 const cleanDisplayName = displayName && displayName.trim() ? displayName.trim() : null; 138 139 await sql` 140 INSERT INTO sites (did, rkey, display_name, created_at, updated_at) 141 VALUES (${did}, ${rkey}, ${cleanDisplayName}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW())) 142 ON CONFLICT (did, rkey) 143 DO UPDATE SET 144 display_name = CASE 145 WHEN EXCLUDED.display_name IS NOT NULL THEN EXCLUDED.display_name 146 ELSE sites.display_name 147 END, 148 updated_at = EXTRACT(EPOCH FROM NOW()) 149 `; 150 } catch (err) { 151 console.error('Failed to upsert site', err); 152 } 153} 154 155export interface SiteRecord { 156 did: string; 157 rkey: string; 158 display_name?: string; 159} 160 161export async function getAllSites(): Promise<SiteRecord[]> { 162 try { 163 const result = await sql<SiteRecord[]>` 164 SELECT did, rkey, display_name FROM sites 165 ORDER BY created_at DESC 166 `; 167 return result; 168 } catch (err) { 169 console.error('Failed to get all sites', err); 170 return []; 171 } 172} 173 174/** 175 * Generate a numeric lock ID from a string key 176 * PostgreSQL advisory locks use bigint (64-bit signed integer) 177 */ 178function stringToLockId(key: string): bigint { 179 const hash = createHash('sha256').update(key).digest('hex'); 180 // Take first 16 hex characters (64 bits) and convert to bigint 181 const hashNum = BigInt('0x' + hash.substring(0, 16)); 182 // Keep within signed int64 range 183 return hashNum & 0x7FFFFFFFFFFFFFFFn; 184} 185 186/** 187 * Acquire a distributed lock using PostgreSQL advisory locks 188 * Returns true if lock was acquired, false if already held by another instance 189 * Lock is automatically released when the transaction ends or connection closes 190 */ 191export async function tryAcquireLock(key: string): Promise<boolean> { 192 const lockId = stringToLockId(key); 193 194 try { 195 const result = await sql`SELECT pg_try_advisory_lock(${Number(lockId)}) as acquired`; 196 return result[0]?.acquired === true; 197 } catch (err) { 198 console.error('Failed to acquire lock', { key, error: err }); 199 return false; 200 } 201} 202 203/** 204 * Release a distributed lock 205 */ 206export async function releaseLock(key: string): Promise<void> { 207 const lockId = stringToLockId(key); 208 209 try { 210 await sql`SELECT pg_advisory_unlock(${Number(lockId)})`; 211 } catch (err) { 212 console.error('Failed to release lock', { key, error: err }); 213 } 214} 215 216export { sql };