File
Blob: src/worker/dav/paths.ts
| 1 | export interface NormalizedDavPath { |
| 2 | href: string; |
| 3 | segments: string[]; |
| 4 | collectionHint: boolean; |
| 5 | } |
| 6 | |
| 7 | export type PathNormalizeResult = |
| 8 | | { ok: true; path: NormalizedDavPath } |
| 9 | | { ok: false; status: number; message: string }; |
| 10 | |
| 11 | export type CollectionObjectPath = |
| 12 | | { kind: "home" } |
| 13 | | { kind: "collection"; collectionName: string } |
| 14 | | { kind: "object"; collectionName: string; objectName: string }; |
| 15 | |
| 16 | function hasEncodedSlash(segment: string): boolean { |
| 17 | return /%2f/i.test(segment) || /%5c/i.test(segment); |
| 18 | } |
| 19 | |
| 20 | function encodeSegment(segment: string): string { |
| 21 | return encodeURIComponent(segment).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`); |
| 22 | } |
| 23 | |
| 24 | export 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 | |
| 60 | function 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 | |
| 71 | export 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 | |
| 96 | export 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 | |
| 103 | export function basename(path: NormalizedDavPath): string | null { |
| 104 | return path.segments[path.segments.length - 1] ?? null; |
| 105 | } |
| 106 | |
| 107 | export function collectionHref(segments: string[]): string { |
| 108 | if (segments.length === 0) return "/files/"; |
| 109 | return `/files/${segments.map(encodeSegment).join("/")}/`; |
| 110 | } |
| 111 | |
| 112 | export function resourceHref(segments: string[], collection: boolean): string { |
| 113 | if (collection) return collectionHref(segments); |
| 114 | return `/files/${segments.map(encodeSegment).join("/")}`; |
| 115 | } |
| 116 | |
| 117 | export 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 | } |