SPB Git

Repository Public

index.ts 6.3 KB 104 lines typescript

1// Rules shared by the API, the worker and the web app. No I/O here.23export const MAX_NAME_BYTES = 255;4export const CHUNK_BYTES = 8 * 1024 * 1024; // upload chunk the web client sends5export const MAX_CHUNK_BYTES = 16 * 1024 * 1024; // largest chunk the API accepts67const RESERVED = new Set(['con', 'prn', 'aux', 'nul', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9']);8// control characters, path separators, characters refused by common file systems, bidi overrides and zero-width marks9const BAD_CHARS = /[\u0000-\u001f\u007f-\u009f/\\:*?"<>|‪-‮⁦-⁩​-‏]/g;1011const utf8Length = (s: string) => new TextEncoder().encode(s).length;12function truncateUtf8(s: string, max: number): string {13  let out = '';14  let n = 0;15  for (const ch of s) { const l = utf8Length(ch); if (n + l > max) break; out += ch; n += l; }16  return out;17}1819/** Split "archive.tar.gz" into ["archive", ".tar.gz"]; a leading dot is part of the stem. */20export function splitExt(name: string): [string, string] {21  const m = /^(.+?)((?:\.tar)?\.[A-Za-z0-9]{1,10})$/.exec(name);22  return m ? [m[1]!, m[2]!] : [name, ''];23}24/** Lower-case extension without the dot ("" when none). */25export const extOf = (name: string): string => { const e = splitExt(name)[1]; return e ? e.slice(e.lastIndexOf('.') + 1).toLowerCase() : ''; };2627/**28 * Turn any client-supplied name into one safe to store, to show, to put in a ZIP and to write on a file system.29 * Never returns a path: separators are replaced, "." and ".." cannot survive, the result is never empty.30 */31export function sanitizeName(raw: string, fallback = 'sans-nom'): string {32  let s = String(raw ?? '').normalize('NFC').replace(BAD_CHARS, '_').replace(/\s+/g, ' ').trim();33  s = s.replace(/^\.+/, '').replace(/[. ]+$/, '').trim(); // no hidden/relative names, no trailing dot or space34  if (!s) s = fallback;35  const [stem, ext] = splitExt(s);36  if (RESERVED.has(stem.toLowerCase())) s = `_${s}`;37  if (utf8Length(s) > MAX_NAME_BYTES) {38    const [st, ex] = splitExt(s);39    const keepExt = utf8Length(ex) <= 20 ? ex : '';40    s = truncateUtf8(st, MAX_NAME_BYTES - utf8Length(keepExt)) + keepExt;41  }42  return s || fallback;43}44/** Key used for uniqueness inside a folder: case- and normalisation-insensitive. */45export const nameKey = (name: string): string => name.normalize('NFC').toLowerCase();4647/** "rapport.pdf" → "rapport (2).pdf" → "rapport (3).pdf" … */48export function nextFreeName(name: string, taken: (key: string) => boolean): string {49  if (!taken(nameKey(name))) return name;50  const [stem0, ext] = splitExt(name);51  const m = /^(.*) \((\d+)\)$/.exec(stem0);52  const stem = m ? m[1]! : stem0;53  for (let i = m ? Number(m[2]) + 1 : 2; i < 100000; i++) {54    const candidate = sanitizeName(`${truncateUtf8(stem, MAX_NAME_BYTES - utf8Length(ext) - 12)} (${i})${ext}`);55    if (!taken(nameKey(candidate))) return candidate;56  }57  throw new Error('no free name');58}5960/** Split a client relative path ("Dossier/sous/fichier.txt") into safe segments; rejects traversal by construction. */61export function safePathSegments(rel: string): string[] {62  return String(rel ?? '').split(/[/\\]+/).filter((p) => p && p !== '.' && p !== '..').map((p) => sanitizeName(p));63}6465export type PreviewKind = 'pdf' | 'image' | 'heic' | 'svg' | 'video' | 'audio' | 'text' | 'markdown' | 'csv' | 'json' | 'office' | 'sheet' | 'archive' | 'none';6667const TEXT_EXT = new Set(['txt', 'log', 'ini', 'conf', 'cfg', 'env', 'yaml', 'yml', 'toml', 'xml', 'html', 'htm', 'css', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'kt', 'swift', 'c', 'h', 'cpp', 'hpp', 'cs', 'php', 'sh', 'zsh', 'bash', 'sql', 'r', 'tex', 'bib', 'do', 'm', 'jl', 'lua', 'pl', 'gitignore', 'dockerfile', 'makefile', 'tsv', 'srt', 'vtt', 'rtf']);68const BY_EXT: Record<string, PreviewKind> = {69  pdf: 'pdf', jpg: 'image', jpeg: 'image', png: 'image', webp: 'image', gif: 'image', heic: 'heic', heif: 'heic', svg: 'svg',70  mp4: 'video', m4v: 'video', mov: 'video', webm: 'video', mp3: 'audio', m4a: 'audio', aac: 'audio', wav: 'audio', ogg: 'audio', oga: 'audio', flac: 'audio',71  md: 'markdown', markdown: 'markdown', csv: 'csv', json: 'json', geojson: 'json',72  docx: 'office', doc: 'office', odt: 'office', pptx: 'office', ppt: 'office', odp: 'office', xlsx: 'sheet',73  zip: 'archive', tar: 'archive', tgz: 'archive', gz: 'archive',74};75/** How a file is previewed, decided from its (sanitised) name. The server decides independently what it may serve inline. */76export function previewKind(name: string): PreviewKind {77  const ext = extOf(name);78  if (ext === 'gz') return /\.tar\.gz$/i.test(name) ? 'archive' : 'none';79  return BY_EXT[ext] ?? (TEXT_EXT.has(ext) ? 'text' : 'none');80}8182/** Content types the API may answer with for inline display of user content; everything else is an attachment. */83export const INLINE_MIME: Record<string, string> = {84  pdf: 'application/pdf', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', svg: 'image/svg+xml',85  mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',86  mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', wav: 'audio/wav', ogg: 'audio/ogg', oga: 'audio/ogg', flac: 'audio/flac',87};8889export const formatBytes = (n: number, locale = 'fr'): string => {90  const units = locale === 'fr' ? ['o', 'Ko', 'Mo', 'Go', 'To'] : ['B', 'KB', 'MB', 'GB', 'TB'];91  let v = n, i = 0;92  while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }93  return `${new Intl.NumberFormat(locale, { maximumFractionDigits: v < 10 && i > 0 ? 1 : 0 }).format(v)} ${units[i]}`;94};9596/** Portal form field policy. */97export type FieldMode = 'off' | 'optional' | 'required';98export interface CustomField { key: string; label: string; required: boolean }99export interface RequestFields { name: FieldMode; email: FieldMode; reference: FieldMode; custom: CustomField[] }100export const DEFAULT_FIELDS: RequestFields = { name: 'optional', email: 'optional', reference: 'off', custom: [] };101102/** Extensions a deposit portal never accepts, whatever its allow-list says (executables, scripts, active content). */103export const BLOCKED_EXT: ReadonlySet<string> = new Set(['exe', 'dll', 'msi', 'scr', 'com', 'bat', 'cmd', 'ps1', 'vbs', 'js', 'jse', 'wsf', 'hta', 'jar', 'app', 'dmg', 'pkg', 'sh', 'command', 'lnk', 'apk', 'deb', 'rpm', 'iso', 'html', 'htm', 'svg', 'php']);104
Files main
Menu

Theme