Spaces:
Running
Running
| 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 pages. Each page is its own Yjs document. | |
| // Collab document names: the bare project id for the "home" page (legacy | |
| // compatible), or `${projectId}::${slug}` for other pages. `_structure` is a | |
| // reserved page whose YAML code block defines the sidebar tree. | |
| 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' | |
| } | |