import { buildUrl } from "#lib/api/_request.ts"; import { nsidOf } from "#lib/api/syntax.ts"; // the legacy appview's /archive/ urls, kept so old links and nix lock files resolve the same const ARCHIVE_NSID = nsidOf("org.tangled.temp.git.getArchive"); const ARCHIVE_FORMATS = ["tar.gz", "zip"] as const; const MAX_PREFIX_BYTES = 255; export type ArchiveFormat = (typeof ARCHIVE_FORMATS)[number]; export interface ArchiveRequest { ref: string; format: ArchiveFormat; /** empty when the url left it to the repo name and ref */ prefix: string; /** guessed from the user agent. the response varies on it */ guessed: boolean; } const isFormat = (raw: string | null): raw is ArchiveFormat => ARCHIVE_FORMATS.includes(raw as ArchiveFormat); const pathSeparators = (value: string) => value.replace(/[/\\]/g, "-"); // go-git's ReferenceName.Short const shortRef = (ref: string) => ref.replace(/^refs\/(heads\/|tags\/|remotes\/)?/, ""); const truncateBytes = (value: string, limit: number) => { const bytes = new TextEncoder().encode(value); if (bytes.length <= limit) return value; let end = limit; while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; return new TextDecoder().decode(bytes.subarray(0, end)); }; export const defaultArchivePrefix = (repoName: string, ref: string): string => truncateBytes(`${pathSeparators(repoName)}-${pathSeparators(shortRef(ref))}`, MAX_PREFIX_BYTES); const parsePrefix = (raw: string): string | null => { if (/[\p{Cc}\\]/u.test(raw)) return null; const components = raw.split("/").filter((c) => c !== "" && c !== "."); if (components.includes("..")) return null; const prefix = components.join("/"); return new TextEncoder().encode(prefix).length > MAX_PREFIX_BYTES ? null : prefix; }; // git refuses .. in ref names, and a range would archive only one end of it const isRef = (ref: string) => ref !== "" && !/[\s\p{Cc}]/u.test(ref) && !ref.startsWith("-") && !ref.includes(".."); export const parseArchiveRequest = ( path: string, query: URLSearchParams, userAgent: string ): ArchiveRequest | null => { const suffix = ARCHIVE_FORMATS.find((format) => path.endsWith(`.${format}`)); const ref = suffix ? path.slice(0, -(suffix.length + 1)) : path; const prefix = parsePrefix(query.get("prefix") ?? ""); if (!isRef(ref) || prefix === null) return null; const requested = query.get("format"); if (isFormat(requested)) return { ref, format: requested, prefix, guessed: false }; if (suffix) return { ref, format: suffix, prefix, guessed: false }; const windows = /windows|win64|win32/.test(userAgent.toLowerCase()); return { ref, format: windows ? "zip" : "tar.gz", prefix, guessed: true }; }; export const archiveUrl = ( serviceUrl: string, params: { repo: string; ref: string; format: ArchiveFormat; prefix: string } ): string => buildUrl(serviceUrl, ARCHIVE_NSID, params).toString(); // the url nix locks a flake input to. it carries every parameter that shapes the bytes export const immutableArchiveUrl = ( origin: string, repoDid: string, archive: { ref: string; format: ArchiveFormat; prefix: string } ): string => `${origin}/${repoDid}/archive/${archive.ref}.${archive.format}?${new URLSearchParams({ prefix: archive.prefix })}`;