share-link-repository.ts
1 import crypto from 'node:crypto' 2 import { 3 loadCollection, 4 loadStoredItem, 5 upsertStoredItem, 6 deleteStoredItem, 7 } from '@/lib/server/storage' 8 import { genId } from '@/lib/id' 9 10 export type ShareEntityType = 'mission' | 'skill' | 'session' 11 12 export interface ShareLink { 13 id: string 14 token: string 15 entityType: ShareEntityType 16 entityId: string 17 label: string | null 18 createdAt: number 19 expiresAt: number | null 20 revokedAt: number | null 21 } 22 23 const TOKEN_BYTES = 24 // 32 base64url chars 24 25 function generateToken(): string { 26 return crypto.randomBytes(TOKEN_BYTES).toString('base64url') 27 } 28 29 export function listShareLinks(): ShareLink[] { 30 const rows = loadCollection('share_links') 31 return Object.values(rows).map((raw) => normalizeShareLink(raw as Record<string, unknown>)) 32 } 33 34 export function loadShareLinkById(id: string): ShareLink | null { 35 const raw = loadStoredItem('share_links', id) 36 return raw ? normalizeShareLink(raw as Record<string, unknown>) : null 37 } 38 39 export function loadShareLinkByToken(token: string): ShareLink | null { 40 const trimmed = token.trim() 41 if (!trimmed) return null 42 for (const link of listShareLinks()) { 43 if (link.token === trimmed) return link 44 } 45 return null 46 } 47 48 export interface CreateShareLinkInput { 49 entityType: ShareEntityType 50 entityId: string 51 expiresInSec?: number | null 52 label?: string | null 53 } 54 55 export function createShareLink(input: CreateShareLinkInput): ShareLink { 56 const now = Date.now() 57 const link: ShareLink = { 58 id: genId(), 59 token: generateToken(), 60 entityType: input.entityType, 61 entityId: input.entityId, 62 label: input.label?.trim() || null, 63 createdAt: now, 64 expiresAt: 65 input.expiresInSec && input.expiresInSec > 0 66 ? now + input.expiresInSec * 1000 67 : null, 68 revokedAt: null, 69 } 70 upsertStoredItem('share_links', link.id, link) 71 return link 72 } 73 74 export function revokeShareLink(id: string): ShareLink | null { 75 const link = loadShareLinkById(id) 76 if (!link) return null 77 if (link.revokedAt) return link 78 const next: ShareLink = { ...link, revokedAt: Date.now() } 79 upsertStoredItem('share_links', next.id, next) 80 return next 81 } 82 83 export function deleteShareLink(id: string): void { 84 deleteStoredItem('share_links', id) 85 } 86 87 export function isShareLinkActive(link: ShareLink, now: number = Date.now()): boolean { 88 if (link.revokedAt) return false 89 if (link.expiresAt !== null && link.expiresAt <= now) return false 90 return true 91 } 92 93 function normalizeShareLink(raw: Record<string, unknown>): ShareLink { 94 const entityType = raw.entityType 95 const safeEntityType: ShareEntityType = 96 entityType === 'mission' || entityType === 'skill' || entityType === 'session' ? entityType : 'mission' 97 return { 98 id: typeof raw.id === 'string' ? raw.id : '', 99 token: typeof raw.token === 'string' ? raw.token : '', 100 entityType: safeEntityType, 101 entityId: typeof raw.entityId === 'string' ? raw.entityId : '', 102 label: typeof raw.label === 'string' ? raw.label : null, 103 createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : 0, 104 expiresAt: typeof raw.expiresAt === 'number' ? raw.expiresAt : null, 105 revokedAt: typeof raw.revokedAt === 'number' ? raw.revokedAt : null, 106 } 107 }