File
Blob: src/worker/dav/locks.ts
| 1 | import type { ParsedIfHeader } from "@/worker/dav/if-header"; |
| 2 | import { isSameOrDescendantPath, normalizeFilesPath } from "@/worker/dav/paths"; |
| 3 | import type { FileLockRow } from "@/worker/db/file-dav-do/schema"; |
| 4 | import type { LockScope } from "@/worker/db/types"; |
| 5 | |
| 6 | export function normalizeLockToken(token: string): string { |
| 7 | return token.startsWith("<") && token.endsWith(">") ? token.slice(1, -1) : token; |
| 8 | } |
| 9 | |
| 10 | function hasLockToken(tokens: string[], token: string): boolean { |
| 11 | const wanted = normalizeLockToken(token); |
| 12 | return tokens.map(normalizeLockToken).includes(wanted); |
| 13 | } |
| 14 | |
| 15 | export function lockAppliesToHref(lock: FileLockRow, href: string): boolean { |
| 16 | if (lock.rootPath === href) return true; |
| 17 | return lock.depth === "infinity" && isSameOrDescendantPath(href, lock.rootPath); |
| 18 | } |
| 19 | |
| 20 | function lockOverlapsHref(lock: FileLockRow, href: string): boolean { |
| 21 | return lockAppliesToHref(lock, href) || isSameOrDescendantPath(lock.rootPath, href); |
| 22 | } |
| 23 | |
| 24 | export function findLockConflict( |
| 25 | locks: FileLockRow[], |
| 26 | href: string, |
| 27 | submittedTokens: string[], |
| 28 | requestedScope: LockScope | null, |
| 29 | ): FileLockRow | null { |
| 30 | for (const lock of locks) { |
| 31 | if (!lockOverlapsHref(lock, href)) continue; |
| 32 | if (hasLockToken(submittedTokens, lock.token)) continue; |
| 33 | if (requestedScope === null || requestedScope === "exclusive" || lock.scope === "exclusive") return lock; |
| 34 | } |
| 35 | return null; |
| 36 | } |
| 37 | |
| 38 | export function findLockConflictOnResource( |
| 39 | locks: FileLockRow[], |
| 40 | href: string, |
| 41 | submittedTokens: string[], |
| 42 | requestedScope: LockScope | null, |
| 43 | ): FileLockRow | null { |
| 44 | for (const lock of locks) { |
| 45 | if (!lockAppliesToHref(lock, href)) continue; |
| 46 | if (hasLockToken(submittedTokens, lock.token)) continue; |
| 47 | if (requestedScope === null || requestedScope === "exclusive" || lock.scope === "exclusive") return lock; |
| 48 | } |
| 49 | return null; |
| 50 | } |
| 51 | |
| 52 | // RFC 4918 6.1 / 7.3: a new LOCK request must not bypass conflicts even if the |
| 53 | // requester supplies an existing lock token. Tokens authorize writes against an |
| 54 | // existing lock; they do not authorize the creation of an overlapping new lock. |
| 55 | export function findNewLockConflict(locks: FileLockRow[], href: string, requestedScope: LockScope): FileLockRow | null { |
| 56 | for (const lock of locks) { |
| 57 | if (!lockOverlapsHref(lock, href)) continue; |
| 58 | if (requestedScope === "exclusive" || lock.scope === "exclusive") return lock; |
| 59 | } |
| 60 | return null; |
| 61 | } |
| 62 | |
| 63 | function normalizeIfResourceHref(href: string): string { |
| 64 | let pathname = href; |
| 65 | try { |
| 66 | pathname = new URL(href).pathname; |
| 67 | } catch { |
| 68 | // Tagged If headers may also carry origin-form hrefs such as /files/a.txt. |
| 69 | } |
| 70 | |
| 71 | const normalized = normalizeFilesPath(pathname); |
| 72 | return normalized.ok ? normalized.path.href : pathname; |
| 73 | } |
| 74 | |
| 75 | function lockTokenConditionMatches(locks: FileLockRow[], href: string, token: string): boolean { |
| 76 | const normalized = normalizeLockToken(token); |
| 77 | return locks.some((lock) => lock.token === normalized && lockAppliesToHref(lock, href)); |
| 78 | } |
| 79 | |
| 80 | export function ifHeaderMatches(input: { |
| 81 | header: ParsedIfHeader; |
| 82 | targetHref: string; |
| 83 | targetEtag: string | null; |
| 84 | locks: FileLockRow[]; |
| 85 | getEtag?: (href: string) => string | null; |
| 86 | }): boolean { |
| 87 | if (input.header.lists.length === 0) return true; |
| 88 | |
| 89 | for (const list of input.header.lists) { |
| 90 | const conditionHref = list.resourceHref ? normalizeIfResourceHref(list.resourceHref) : input.targetHref; |
| 91 | const listMatches = list.conditions.every((condition) => { |
| 92 | const matches = |
| 93 | condition.kind === "etag" |
| 94 | ? (conditionHref === input.targetHref ? input.targetEtag : (input.getEtag?.(conditionHref) ?? null)) === |
| 95 | condition.value |
| 96 | : condition.kind === "lock-token" |
| 97 | ? lockTokenConditionMatches(input.locks, conditionHref, condition.value) |
| 98 | : false; |
| 99 | return condition.not ? !matches : matches; |
| 100 | }); |
| 101 | if (listMatches) return true; |
| 102 | } |
| 103 | |
| 104 | return false; |
| 105 | } |