Skip to content
File

Blob: src/worker/dav/paths.ts

typescript122 lines
1export interface NormalizedDavPath {
2 href: string;
3 segments: string[];
4 collectionHint: boolean;
5}
6 
7export type PathNormalizeResult =
8 | { ok: true; path: NormalizedDavPath }
9 | { ok: false; status: number; message: string };
10 
11export type CollectionObjectPath =
12 | { kind: "home" }
13 | { kind: "collection"; collectionName: string }
14 | { kind: "object"; collectionName: string; objectName: string };
15 
16function hasEncodedSlash(segment: string): boolean {
17 return /%2f/i.test(segment) || /%5c/i.test(segment);
18}
19 
20function encodeSegment(segment: string): string {
21 return encodeURIComponent(segment).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
22}
23 
24export function normalizeFilesPath(pathname: string): PathNormalizeResult {
25 if (pathname !== "/files" && pathname !== "/files/" && !pathname.startsWith("/files/")) {
26 return { ok: false, status: 404, message: "Not found" };
27 }
28 
29 const rawRemainder = pathname === "/files" ? "" : pathname.slice("/files/".length);
30 const collectionHint = pathname === "/files" || pathname.endsWith("/");
31 const segments: string[] = [];
32 
33 for (const rawSegment of rawRemainder.split("/")) {
34 if (!rawSegment) continue;
35 if (hasEncodedSlash(rawSegment)) return { ok: false, status: 400, message: "Encoded slashes are not allowed" };
36 
37 let segment: string;
38 try {
39 segment = decodeURIComponent(rawSegment);
40 } catch {
41 return { ok: false, status: 400, message: "Invalid percent encoding" };
42 }
43 
44 if (segment === "." || segment === "") continue;
45 if (segment === "..") {
46 segments.pop();
47 continue;
48 }
49 if (/[\u0000-\u001f\u007f]/u.test(segment)) {
50 return { ok: false, status: 400, message: "Control characters are not allowed in DAV paths" };
51 }
52 segments.push(segment);
53 }
54 
55 const encoded = segments.map(encodeSegment).join("/");
56 const href = segments.length === 0 ? "/files/" : `/files/${encoded}${collectionHint ? "/" : ""}`;
57 return { ok: true, path: { href, segments, collectionHint: collectionHint || segments.length === 0 } };
58}
59 
60function decodeCollectionSegment(raw: string): string | null {
61 if (hasEncodedSlash(raw)) return null;
62 try {
63 const segment = decodeURIComponent(raw);
64 if (/[\u0000-\u001f\u007f]/u.test(segment)) return null;
65 return segment;
66 } catch {
67 return null;
68 }
69}
70 
71export function parseCollectionObjectPath(pathname: string, basePath: string): CollectionObjectPath | null {
72 if (pathname === basePath || pathname === `${basePath}/`) return { kind: "home" };
73 if (!pathname.startsWith(`${basePath}/`)) return null;
74 
75 const trailingCollection = pathname.endsWith("/");
76 const parts: string[] = [];
77 for (const rawPart of pathname.slice(`${basePath}/`.length).split("/")) {
78 if (!rawPart) continue;
79 const part = decodeCollectionSegment(rawPart);
80 if (part === null) return null;
81 if (part === ".") continue;
82 if (part === "..") {
83 parts.pop();
84 continue;
85 }
86 parts.push(part);
87 }
88 
89 const [collectionName, objectName, extra] = parts as string[];
90 if (!collectionName || extra) return null;
91 if (!objectName) return { kind: "collection", collectionName };
92 if (trailingCollection) return null;
93 return { kind: "object", collectionName, objectName };
94}
95 
96export function parentHref(path: NormalizedDavPath): string | null {
97 if (path.segments.length === 0) return null;
98 const parentSegments = path.segments.slice(0, -1);
99 if (parentSegments.length === 0) return "/files/";
100 return `/files/${parentSegments.map(encodeSegment).join("/")}/`;
101}
102 
103export function basename(path: NormalizedDavPath): string | null {
104 return path.segments[path.segments.length - 1] ?? null;
105}
106 
107export function collectionHref(segments: string[]): string {
108 if (segments.length === 0) return "/files/";
109 return `/files/${segments.map(encodeSegment).join("/")}/`;
110}
111 
112export function resourceHref(segments: string[], collection: boolean): string {
113 if (collection) return collectionHref(segments);
114 return `/files/${segments.map(encodeSegment).join("/")}`;
115}
116 
117export function isSameOrDescendantPath(candidateHref: string, ancestorHref: string): boolean {
118 if (candidateHref === ancestorHref) return true;
119 const base = ancestorHref.endsWith("/") ? ancestorHref : `${ancestorHref}/`;
120 return candidateHref.startsWith(base);
121}