/ src / lib / server / sharing / share-link-repository.ts
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  }