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