~james/core

core/web/src/lib/api/archive.ts -rw-r--r-- 3.1 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
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
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 })}`;