feat(webapp): old URLs follow a moved file, and share links follow the file (BEA-81) (#130)

There is no rename in beardrive: the scanner emits a put at the new path and
a delete at the old, same device, same blob, one cycle. Everything keyed on a
path therefore broke the moment a file moved — the viewer 404'd, history lost
the file's own past versions, restore refused them, and a share link either
404'd or silently served whatever unrelated file later took its address.

internal/webapp/moves.go derives the pairing from the ops the replay already
walks, cached with the snapshot. Deliberately not a rename op: journal.Less
and Replay are what every device converges to, and every already-shipped
journal would still need the heuristic to read its own history.

The two rules point in opposite directions on purpose. A viewer URL is an
address, so a LIVE path always wins and only an empty one redirects. A share
token is a promise about one file, so it follows the file even when a new one
takes the old address — and 404s forever once the file is deleted.

Nothing here writes an op or touches sync.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-11 02:52:20 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent aa33ba55cc
commit e19fec0534
19 changed files with 1258 additions and 144 deletions
+2
View File
@@ -31,7 +31,9 @@ classDiagram
class Browser {
folder listing, file view
per-view routes
+moved: /resolve?path= on a tree miss only
}
note for Browser "A missing path is decided from /tree alone — the file is never fetched — so the X-Bdrive-Canonical-Path header /file answers with would never reach the browser, and a moved FOLDER has no content fetch to hang a header on. The not-found branch asks GET /resolve?path= instead, then replaceState-navigates to the destination and prints one Moved from … line above it (BEA-81)"
class router {
+VIEW_ROUTES dashboard history install settings
+30 -1
View File
@@ -37,7 +37,7 @@ classDiagram
class volume {
-source Source
-refresh time.Duration
-snap *snapshot
-snap *snapshot (files + moves)
+snapshot(ctx)
+invalidate()
}
@@ -68,6 +68,30 @@ classDiagram
}
note for sourcedOp "An op's Device field is whatever the writer typed; From is the journal object it actually came out of, which the /store door gates. Attribution reads From — a peer cannot sign someone else's name on a change by editing its own journal"
note for RemoteSource "OpenBlob is the single blob-read door: the sha must match blobRe, and verify re-hashes the bytes whenever the backend is a PutSigner — in direct-upload mode the server never saw the content, so the store is the only thing that could have swapped it. It stops re-hashing only once the object is PROVABLY immutable: both presign doors refuse a key that exists, so every URL for a blob was minted before its first PUT and dies at mint+PresignTTL; past that age the hub is the only writer left. That is what remote.Object.Modified is for"
class MoveSource {
<<interface>>
+FilesWithMoves(ctx) files, moveIndex
}
class moveIndex {
<<map path→[]pathEvent>>
+buildMoveIndex(sorted ops)
+resolveForward(idx, files, p) viewer
+resolveShare(idx, files, p, since) /s/
+chainSegments(idx, p) []segment
+resolveFolder(idx, files, dir) all-or-nothing
}
class pathEvent {
+At the delete that ended it
+To "" = deleted, not moved
+ToAt destination's create
}
class segment {
+Path
+From, To window it WAS the file
}
note for moveIndex "There is no rename op — a move is put(new) + delete(old), same device, same blob, one cycle — so the index is DERIVED inside the replay Files already runs and cached with the snapshot. Pairing needs same device, |Δt| ≤ 30s, B's first-ever put, and one-to-one both ways; anything ambiguous stays a plain deletion. Nothing here writes an op: journal.Less and Replay are untouched"
note for segment "Time-bounded on purpose: a bare set of paths would make history?path=docs/a.md show the ops of the NEW a.md that took the old address"
class Uploader {
<<interface>>
+Upload(ctx, path, r, size, who, note)
@@ -346,6 +370,11 @@ classDiagram
Source <|.. DirSource
Source <|.. RemoteSource
MoveSource <|.. RemoteSource : optional, like Uploader — DirSource has no journals, so no moves
MoveSource ..> moveIndex
volume o-- moveIndex : cached with the snapshot
moveIndex *-- pathEvent
moveIndex ..> segment : chainSegments
Uploader <|-- DirectUploader
DirectUploader <|.. RemoteSource
RemoteSource o-- Backend : Prefixed(Root, projectID)