File size: 3,111 Bytes
99a44ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e5e233a
 
9b49194
 
 
e5e233a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'

const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'

export function newId(len = 10) {
  const bytes = crypto.randomBytes(len)
  let out = ''
  for (let i = 0; i < len; i++) out += ID_ALPHABET[bytes[i] % ID_ALPHABET.length]
  return out
}

export function atomicWriteFile(filePath, data) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true })
  const tmp = path.join(path.dirname(filePath), `.tmp-${newId(6)}`)
  fs.writeFileSync(tmp, data)
  fs.renameSync(tmp, filePath)
}

export function readJSON(filePath, fallback) {
  try {
    return JSON.parse(fs.readFileSync(filePath, 'utf8'))
  } catch {
    return fallback
  }
}

export function writeJSON(filePath, value) {
  atomicWriteFile(filePath, JSON.stringify(value, null, 2))
}

export function b64encode(uint8) {
  return Buffer.from(uint8).toString('base64url')
}

export function b64decode(str) {
  return new Uint8Array(Buffer.from(str, 'base64url'))
}

// --- signed session cookies ---

export function signSession(payload, secret) {
  const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
  const mac = crypto.createHmac('sha256', secret).update(body).digest('base64url')
  return `${body}.${mac}`
}

export function verifySession(value, secret) {
  if (!value || !value.includes('.')) return null
  const [body, mac] = value.split('.')
  const expected = crypto.createHmac('sha256', secret).update(body).digest('base64url')
  const a = Buffer.from(mac)
  const b = Buffer.from(expected)
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
  try {
    return JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
  } catch {
    return null
  }
}

export function parseCookies(header) {
  const out = {}
  if (!header) return out
  for (const part of header.split(';')) {
    const idx = part.indexOf('=')
    if (idx === -1) continue
    out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim())
  }
  return out
}

export const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{1,38}$/
export const DOC_ID_RE = /^[A-Za-z0-9_-]{1,64}$/

// --- multi-page projects ---
// A project (former "doc") holds named page fragments inside one Yjs document.
// API code still uses `${projectId}::${slug}` as a convenient logical page name;
// the collaboration socket and persisted state use the bare project id.
export const PAGE_SLUG_RE = /^(_structure|[a-z0-9][a-z0-9-]{0,39})$/
export const DOC_NAME_RE = /^[A-Za-z0-9_-]{1,64}(::(_structure|[a-z0-9][a-z0-9-]{0,39}))?$/

export function projectIdOf(docName) {
  return String(docName).split('::')[0]
}

export function pageSlugOf(docName) {
  const parts = String(docName).split('::')
  return parts[1] || 'home'
}

export function docNameFor(projectId, slug) {
  return !slug || slug === 'home' ? projectId : `${projectId}::${slug}`
}

export function slugify(title) {
  const s = String(title || '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 40)
  return s || 'page'
}