import { buildUrl } from "#lib/api/_request.ts";
import { nsidOf } from "#lib/api/syntax.ts";
// the legacy appview's /archive/<ref> 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 })}`;