mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
aa33ba55cc
commit
e19fec0534
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -765,3 +765,68 @@ func sha256hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// A rename is not an op — the scanner emits a put at the new path and a
|
||||
// delete at the old, in one cycle, carrying the same blob. The hub infers
|
||||
// moves from exactly that shape (internal/webapp/moves.go), which only stays
|
||||
// true while sync keeps producing it. Nothing in the move index touches
|
||||
// journal.Less or Replay; this is what pins that.
|
||||
func TestRenameConvergesAsPutPlusDelete(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
b := newDevice(t, "devb", be)
|
||||
|
||||
write(t, a.Folder, "plan.md", "the plan")
|
||||
cycle(t, a)
|
||||
cycle(t, b)
|
||||
if got := read(t, b.Folder, "plan.md"); got != "the plan" {
|
||||
t.Fatalf("b before the rename = %q", got)
|
||||
}
|
||||
|
||||
// The rename, exactly as a person or an editor does it.
|
||||
if err := os.MkdirAll(filepath.Join(a.Folder, "notes"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(filepath.Join(a.Folder, "plan.md"), filepath.Join(a.Folder, "notes", "plan.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := cycle(t, a)
|
||||
if res.LocalOps != 2 {
|
||||
t.Fatalf("LocalOps = %d, want 2 (the put and the delete)", res.LocalOps)
|
||||
}
|
||||
cycle(t, b)
|
||||
|
||||
if got := read(t, b.Folder, "notes/plan.md"); got != "the plan" {
|
||||
t.Fatalf("b after the rename = %q, want the plan", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(b.Folder, "plan.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("the old path survived on b: %v", err)
|
||||
}
|
||||
|
||||
// The two halves the hub pairs on: one device, same blob, same cycle.
|
||||
ops, err := journal.ReadFile(a.Store.JournalPath(a.Device.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var put, del *journal.Op
|
||||
for i := range ops {
|
||||
switch {
|
||||
case ops[i].Kind == journal.KindPut && ops[i].Path == "notes/plan.md":
|
||||
put = &ops[i]
|
||||
case ops[i].Kind == journal.KindDelete && ops[i].Path == "plan.md":
|
||||
del = &ops[i]
|
||||
}
|
||||
}
|
||||
if put == nil || del == nil {
|
||||
t.Fatalf("rename did not journal a put+delete pair: %+v", ops)
|
||||
}
|
||||
if put.Device != del.Device {
|
||||
t.Fatalf("halves on different devices: %q vs %q", put.Device, del.Device)
|
||||
}
|
||||
if want := ops[0].Blob; put.Blob != want {
|
||||
t.Fatalf("the moved file's blob changed: %q, want %q", put.Blob, want)
|
||||
}
|
||||
if d := del.Time.Sub(put.Time); d > 30*time.Second || d < -30*time.Second {
|
||||
t.Fatalf("the halves landed %v apart — wider than the hub's pairing window", d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +252,19 @@ func seedE2E(t *testing.T, state, prefix, projectID string) {
|
||||
// A second version of the same binary, so the history diff has a
|
||||
// predecessor to refuse to diff (the "binary — no diff" path).
|
||||
put("assets/logo.png", png+"\x00trailing", 3*time.Hour)
|
||||
// A file that MOVED: the same blob put at the new path and the old path
|
||||
// deleted, one device, one cycle — the shape the scanner emits for a
|
||||
// rename. The old URL has to keep working (BEA-81).
|
||||
put("old-guide.md", "# Old guide\n\nThis file has been moved.\n", 30*time.Hour)
|
||||
put("archive/moved-guide.md", "# Old guide\n\nThis file has been moved.\n", 5*time.Hour)
|
||||
lam++
|
||||
seq++
|
||||
ops = append(ops, journal.Op{
|
||||
Seq: seq, Lamport: lam, Time: now.Add(-5 * time.Hour).Add(time.Second),
|
||||
Device: "seed", DeviceName: "seed-agent", Author: "alice@x.io",
|
||||
User: "alice@x.io", UserName: "Alice",
|
||||
Kind: journal.KindDelete, Path: "old-guide.md",
|
||||
})
|
||||
// One removed file, so the history feed has a delete row: deletes have no
|
||||
// content, so their rows stay unclickable while every other row is now an
|
||||
// address for its own version.
|
||||
|
||||
@@ -808,6 +808,27 @@ test("an old version of an extensionless file previews the same way", async ({ p
|
||||
await expect(page.locator("#content .empty")).toContainText("That version isn't available.");
|
||||
});
|
||||
|
||||
// BEA-81: an old URL for a file that has since been renamed or dragged into
|
||||
// a folder still lands on the file, rewrites itself, and says what happened.
|
||||
test("a moved file's old URL redirects and says so", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/old-guide.md`);
|
||||
await page.waitForURL(`/${pid}/archive/moved-guide.md`);
|
||||
await expect(page.locator("#content")).toContainText("This file has been moved");
|
||||
await expect(page.locator(".vbanner")).toContainText("Moved from old-guide.md");
|
||||
// replace, not push: Back must not bounce off the dead URL forever.
|
||||
await expect(page.locator(".notfound")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a path that never existed still gets the not-found card", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/nothing-here.md`);
|
||||
await expect(page.locator(".notfound")).toBeVisible();
|
||||
await expect(page.locator(".vbanner")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// BEA-74: .csv/.tsv render as a table, and anything the parser can't make a
|
||||
// table of stays the plain-text view it is today.
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { atLeast } from "../api/types";
|
||||
import { postJSON } from "../api/http";
|
||||
import { getJSON, postJSON } from "../api/http";
|
||||
import type { Project, ServerConfig } from "../api/types";
|
||||
import { useHeat, useTree } from "../hooks/useBrowse";
|
||||
import { useShares } from "../hooks/useHub";
|
||||
@@ -78,6 +78,29 @@ export default function Browser(props: {
|
||||
const isMissing = !!path && loaded && !isDir && !isFile;
|
||||
const listingShowing = isDir && !route.view;
|
||||
|
||||
/* ---- an address whose file moved ----
|
||||
Files get renamed and dragged into folders, and the old URL is already
|
||||
in someone's notes. The server can pair the delete with the put that
|
||||
carried the same blob, so ask it — but only once the tree says the path
|
||||
is gone, so the happy path costs nothing. It is a separate call rather
|
||||
than the X-Bdrive-Canonical-Path header /file answers with, because we
|
||||
never fetch a missing file at all, and a moved FOLDER has no content
|
||||
fetch to hang a header on. */
|
||||
const { data: moved } = useQuery({
|
||||
queryKey: ["resolve", apiBase, path],
|
||||
queryFn: () =>
|
||||
getJSON<{ to: string; kind: string }>(apiBase + "resolve?path=" + encodeURIComponent(path)),
|
||||
enabled: isMissing,
|
||||
retry: false, // a 404 here is the normal answer, not a flake
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const [movedFrom, setMovedFrom] = useState<{ from: string; to: string } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isMissing || !moved?.to) return;
|
||||
setMovedFrom({ from: path, to: moved.to });
|
||||
navigate(urlForPath(moved.to, project?.id), { replace: true });
|
||||
}, [isMissing, moved, path, project?.id]);
|
||||
|
||||
/* ---- tree expansion ---- */
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const firstLoad = useRef(true);
|
||||
@@ -464,6 +487,26 @@ export default function Browser(props: {
|
||||
view = <div className="empty">Select a file to read it.</div>;
|
||||
}
|
||||
|
||||
// Arriving here by redirect: say so, or the URL silently changed under a
|
||||
// reader who typed the other one. Above whatever the destination renders,
|
||||
// so a moved folder gets it too.
|
||||
if (movedFrom && movedFrom.to === path) {
|
||||
view = (
|
||||
<>
|
||||
<div className="vbanner" role="status">
|
||||
<span className="vb-icon">
|
||||
<Icon name="link" />
|
||||
</span>
|
||||
<div className="vb-text">
|
||||
<b>Moved from {movedFrom.from}</b>
|
||||
<span>The URL has been updated.</span>
|
||||
</div>
|
||||
</div>
|
||||
{view}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const crumb = panel ? (
|
||||
panel.crumb
|
||||
) : path ? (
|
||||
|
||||
@@ -216,11 +216,24 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
|
||||
op journal.Op
|
||||
}
|
||||
visible := s.deviceVisibleIn(projectID(r))
|
||||
// A file that moved keeps its past — under its old path. ?path= resolves
|
||||
// through the move chain so the feed for docs/a.md includes the versions
|
||||
// written while it was a.md. Each hop is time-bounded (see segment), so
|
||||
// an unrelated NEW a.md created after the move does not leak in. `all`
|
||||
// is already sorted by journal.Less, so this costs no extra I/O.
|
||||
var chain []segment
|
||||
if path != "" {
|
||||
ops := make([]journal.Op, len(all))
|
||||
for i, sop := range all {
|
||||
ops[i] = sop.Op
|
||||
}
|
||||
chain = chainSegments(buildMoveIndex(ops), path)
|
||||
}
|
||||
matched := make([]timed, 0, len(all))
|
||||
for i, sop := range all {
|
||||
op := sop.Op
|
||||
switch {
|
||||
case path != "" && op.Path != path:
|
||||
case path != "" && !inSegments(chain, op.Path, op.Time):
|
||||
continue
|
||||
case path == "" && prefix != "" && !strings.HasPrefix(op.Path, strings.TrimSuffix(prefix, "/")+"/"):
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/journal"
|
||||
)
|
||||
|
||||
// Moves. There is no rename in beardrive: the scanner emits a put for the
|
||||
// path it has never seen and a delete for the cache key it no longer sees,
|
||||
// both in the same cycle, from the same device, carrying the same blob
|
||||
// (internal/syncer/syncer.go). journal.Op has put and delete and nothing
|
||||
// else, so a move is only ever inferred — never recorded.
|
||||
//
|
||||
// Everything keyed on a path therefore breaks the moment a file moves: the
|
||||
// viewer 404s, history loses the file's own past versions, restore refuses
|
||||
// them, and a share link either 404s or — worse — silently serves whatever
|
||||
// unrelated file later lands on the address it was minted for.
|
||||
//
|
||||
// This file derives the pairing from the ops the replay already walks. It
|
||||
// deliberately does NOT add 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. Derivation is reversible; a
|
||||
// new op kind is not.
|
||||
//
|
||||
// Everything here is read-side. Nothing in this file writes an op.
|
||||
|
||||
// moveWindow is how far apart the two halves of a move may land. Wider than
|
||||
// one daemon cycle (--scan-interval 3s, so a move split across two cycles
|
||||
// still pairs), narrower than a person's editing session. It is the one
|
||||
// number worth revisiting under real journals.
|
||||
const moveWindow = 30 * time.Second
|
||||
|
||||
// maxChain bounds every walk in this file. Op.Time and Op.Path are peer JSON,
|
||||
// so a journal can describe a cycle (A→B→A); a visited set alone stops the
|
||||
// loop, and this stops a pathological chain from making one request walk a
|
||||
// journal-sized graph.
|
||||
const maxChain = 64
|
||||
|
||||
// pathEvent is one moment a path stopped being the file it was.
|
||||
type pathEvent struct {
|
||||
At time.Time // the delete that ended it
|
||||
To string // where the content went; "" = deleted, not moved
|
||||
ToAt time.Time // when the destination was created (move only)
|
||||
}
|
||||
|
||||
// hop is the instant the file changed address. The two halves of a move
|
||||
// arrive in either order (an in-cycle move is put-then-delete; one split
|
||||
// across cycles is delete-then-put), so the boundary between the old path's
|
||||
// window and the new one's is the earlier of the two — otherwise the
|
||||
// destination's own creating op falls outside its own history.
|
||||
func (e pathEvent) hop() time.Time {
|
||||
if e.To != "" && !e.ToAt.IsZero() && e.ToAt.Before(e.At) {
|
||||
return e.ToAt
|
||||
}
|
||||
return e.At
|
||||
}
|
||||
|
||||
// moveIndex is, per path, the events that ended it — in time order.
|
||||
//
|
||||
// One time-stamped list rather than two flat maps (movedTo + deletedAt): a
|
||||
// path that is deleted, recreated and then moved has an entry under the same
|
||||
// key in both maps and nothing left to say which came first, so a share
|
||||
// minted before the delete would follow the move and serve the file that
|
||||
// REPLACED its own.
|
||||
type moveIndex map[string][]pathEvent
|
||||
|
||||
// buildMoveIndex pairs deletes with creates. ops must already be sorted by
|
||||
// journal.Less — every caller has them that way (the replay, the history
|
||||
// feed) so the index costs no extra I/O and no extra pass over storage.
|
||||
//
|
||||
// A delete of A pairs with the first-ever put of B when all hold: same
|
||||
// Op.Device (one device's scan produces both halves), |Δt| ≤ moveWindow,
|
||||
// B's blob is the blob A held immediately before its delete (content
|
||||
// identity is the only link the journal gives us), and the pairing is
|
||||
// one-to-one in both directions. Anything ambiguous — duplicated content,
|
||||
// empty files — stays unpaired and becomes a plain deletion. Silence beats
|
||||
// a wrong destination.
|
||||
func buildMoveIndex(ops []journal.Op) moveIndex {
|
||||
type half struct {
|
||||
path, blob, dev string
|
||||
at time.Time
|
||||
}
|
||||
var creates, deletes []half
|
||||
live := map[string]string{} // path -> the blob it currently holds
|
||||
ever := map[string]bool{} // path has ever been put
|
||||
for _, op := range ops {
|
||||
switch op.Kind {
|
||||
case journal.KindPut:
|
||||
// Files ignores a put whose Blob is not a bare sha256 (it is a
|
||||
// storage key suffix, so anything else is a path the writer
|
||||
// chose). The index has to agree, or it would pair against a
|
||||
// version the viewer never shows.
|
||||
if !blobRe.MatchString(op.Blob) {
|
||||
continue
|
||||
}
|
||||
if !ever[op.Path] {
|
||||
ever[op.Path] = true
|
||||
creates = append(creates, half{op.Path, op.Blob, op.Device, op.Time})
|
||||
}
|
||||
live[op.Path] = op.Blob
|
||||
case journal.KindDelete:
|
||||
blob, ok := live[op.Path]
|
||||
if !ok {
|
||||
continue // deleting nothing
|
||||
}
|
||||
delete(live, op.Path)
|
||||
deletes = append(deletes, half{op.Path, blob, op.Device, op.Time})
|
||||
}
|
||||
}
|
||||
|
||||
key := func(h half) string { return h.dev + "\x00" + h.blob }
|
||||
byKey := map[string][]int{}
|
||||
for i, c := range creates {
|
||||
byKey[key(c)] = append(byKey[key(c)], i)
|
||||
}
|
||||
pairs := func(a, b half) bool {
|
||||
return a.path != b.path && abs(a.at.Sub(b.at)) <= moveWindow
|
||||
}
|
||||
// ponytail: O(n²) inside one (device, blob) bucket. A bucket is the
|
||||
// identical copies of ONE file, not the volume — bucket by time too if
|
||||
// a duplicate-heavy volume ever shows up in a profile.
|
||||
idx := moveIndex{}
|
||||
for _, d := range deletes {
|
||||
ev := pathEvent{At: d.at}
|
||||
var only int = -1
|
||||
for _, i := range byKey[key(d)] {
|
||||
if !pairs(creates[i], d) {
|
||||
continue
|
||||
}
|
||||
if only >= 0 {
|
||||
only = -1 // two candidates: ambiguous, decline
|
||||
break
|
||||
}
|
||||
only = i
|
||||
}
|
||||
if only >= 0 {
|
||||
// ...and one-to-one the other way: two deletes claiming one
|
||||
// create is the same ambiguity seen from the other side.
|
||||
c, n := creates[only], 0
|
||||
for _, d2 := range deletes {
|
||||
if key(d2) == key(c) && pairs(c, d2) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 1 {
|
||||
ev.To, ev.ToAt = c.path, c.at
|
||||
}
|
||||
}
|
||||
idx[d.path] = append(idx[d.path], ev)
|
||||
}
|
||||
for p := range idx {
|
||||
evs := idx[p]
|
||||
sort.SliceStable(evs, func(i, j int) bool { return evs[i].At.Before(evs[j].At) })
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func abs(d time.Duration) time.Duration {
|
||||
if d < 0 {
|
||||
return -d
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// resolveForward answers "this address is empty — where did the file go?".
|
||||
// It follows the LAST event of each path (the most recent move wins) until
|
||||
// it lands somewhere live. Used by the viewer, where a live path always
|
||||
// wins: the caller only reaches here on a snapshot miss.
|
||||
func resolveForward(idx moveIndex, files map[string]FileInfo, p string) (string, bool) {
|
||||
seen := map[string]bool{p: true}
|
||||
for i := 0; i < maxChain; i++ {
|
||||
evs := idx[p]
|
||||
if len(evs) == 0 {
|
||||
return "", false
|
||||
}
|
||||
to := evs[len(evs)-1].To
|
||||
if to == "" || seen[to] {
|
||||
return "", false // deleted, or a cycle a journal described
|
||||
}
|
||||
seen[to] = true
|
||||
if _, ok := files[to]; ok {
|
||||
return to, true
|
||||
}
|
||||
p = to
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// resolveShare answers the opposite question: "where is the file this token
|
||||
// was minted for?" — following the file even when a new one has taken its
|
||||
// old address. A viewer URL is an address; a share token is a promise about
|
||||
// one file, and must never resolve to a file it wasn't minted for.
|
||||
//
|
||||
// since is the share's creation time: only what happened AFTER the link was
|
||||
// minted can move it. A path that was deleted rather than moved is gone,
|
||||
// even if something now occupies the address.
|
||||
func resolveShare(idx moveIndex, files map[string]FileInfo, p string, since time.Time) (string, bool) {
|
||||
seen := map[string]bool{p: true}
|
||||
for i := 0; i < maxChain; i++ {
|
||||
var next *pathEvent
|
||||
for j := range idx[p] {
|
||||
if idx[p][j].At.After(since) {
|
||||
next = &idx[p][j]
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
// Nothing happened to this path since: it is still the file.
|
||||
if _, ok := files[p]; ok {
|
||||
return p, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if next.To == "" || seen[next.To] {
|
||||
return "", false
|
||||
}
|
||||
seen[next.To] = true
|
||||
p, since = next.To, next.At
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// segment is one path plus the window during which it WAS the file being
|
||||
// asked about. 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. A zero From/To is open at that end.
|
||||
type segment struct {
|
||||
Path string
|
||||
From, To time.Time
|
||||
}
|
||||
|
||||
// chainSegments walks backwards: p's own window plus each ancestor's,
|
||||
// ending at the hop that carried the file out of it. Used by history and
|
||||
// restore, so a moved file keeps reaching its own past versions.
|
||||
func chainSegments(idx moveIndex, p string) []segment {
|
||||
type inbound struct {
|
||||
from string
|
||||
hop time.Time
|
||||
}
|
||||
into := map[string][]inbound{}
|
||||
for src, evs := range idx {
|
||||
for _, e := range evs {
|
||||
if e.To != "" {
|
||||
into[e.To] = append(into[e.To], inbound{src, e.hop()})
|
||||
}
|
||||
}
|
||||
}
|
||||
var segs []segment
|
||||
seen := map[string]bool{p: true}
|
||||
cur, upper := p, time.Time{}
|
||||
for i := 0; i < maxChain; i++ {
|
||||
anc := into[cur]
|
||||
// No ancestor, or more than one claiming to be it: stop, leaving
|
||||
// this segment open at the start rather than guessing.
|
||||
if len(anc) != 1 || seen[anc[0].from] {
|
||||
return append(segs, segment{Path: cur, To: upper})
|
||||
}
|
||||
segs = append(segs, segment{Path: cur, From: anc[0].hop, To: upper})
|
||||
seen[anc[0].from] = true
|
||||
cur, upper = anc[0].from, anc[0].hop
|
||||
}
|
||||
return segs
|
||||
}
|
||||
|
||||
// inSegments reports whether an op at path p, time at, belongs to the chain.
|
||||
func inSegments(segs []segment, p string, at time.Time) bool {
|
||||
for _, s := range segs {
|
||||
switch {
|
||||
case s.Path != p:
|
||||
case !s.From.IsZero() && at.Before(s.From):
|
||||
case !s.To.IsZero() && !at.Before(s.To):
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveFolder derives a folder redirect from the file mappings — there are
|
||||
// no folder ops, so a folder move is N file moves. All-or-nothing: every
|
||||
// file that was under dir/ must land on the same newdir/<same suffix>, with
|
||||
// nothing under dir/ still live and no non-move delete in the way. A partial
|
||||
// match gets no folder redirect; the individual files still redirect on
|
||||
// their own.
|
||||
func resolveFolder(idx moveIndex, files map[string]FileInfo, dir string) (string, bool) {
|
||||
dir = strings.Trim(dir, "/")
|
||||
if dir == "" {
|
||||
return "", false
|
||||
}
|
||||
prefix := dir + "/"
|
||||
for p := range files {
|
||||
if strings.HasPrefix(p, prefix) {
|
||||
return "", false // the folder still exists; nothing to redirect
|
||||
}
|
||||
}
|
||||
dest := ""
|
||||
for src := range idx {
|
||||
if !strings.HasPrefix(src, prefix) {
|
||||
continue
|
||||
}
|
||||
to, ok := resolveForward(idx, files, src)
|
||||
if !ok {
|
||||
return "", false // deleted, or ambiguous: no honest destination
|
||||
}
|
||||
suffix := strings.TrimPrefix(src, prefix)
|
||||
if !strings.HasSuffix(to, "/"+suffix) {
|
||||
return "", false
|
||||
}
|
||||
nd := strings.TrimSuffix(to, "/"+suffix)
|
||||
if nd == "" || (dest != "" && nd != dest) {
|
||||
return "", false
|
||||
}
|
||||
dest = nd
|
||||
}
|
||||
return dest, dest != ""
|
||||
}
|
||||
|
||||
// handleResolve serves GET .../resolve?path= — "this address is empty, where
|
||||
// did it go?". The SPA calls it only on a miss (it decides a path is missing
|
||||
// from /tree alone and never fetches the file, so the canonical header on
|
||||
// /file would never reach the browser) and it is the only shape that covers
|
||||
// a moved folder, which has no content fetch to hang a header on.
|
||||
func (s *Server) handleResolve(v *volume, w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Query().Get("path")
|
||||
if p == "" {
|
||||
http.Error(w, "missing ?path=", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
snap, err := v.snapshot(r.Context())
|
||||
if err != nil {
|
||||
storageErr(w, http.StatusBadGateway, "content temporarily unavailable", err)
|
||||
return
|
||||
}
|
||||
if _, live := snap.files[p]; !live {
|
||||
if to, ok := resolveForward(snap.moves, snap.files, p); ok {
|
||||
writeJSON(w, map[string]string{"to": to, "kind": "file"})
|
||||
return
|
||||
}
|
||||
if to, ok := resolveFolder(snap.moves, snap.files, p); ok {
|
||||
writeJSON(w, map[string]string{"to": to, "kind": "folder"})
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "no such file: "+p, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// setCanonical tells the caller the path moved. The value is the path that
|
||||
// answered; a client that cares rewrites its URL to it.
|
||||
func setCanonical(w http.ResponseWriter, r *http.Request, p string) {
|
||||
if p != r.URL.Query().Get("path") {
|
||||
w.Header().Set("X-Bdrive-Canonical-Path", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/journal"
|
||||
)
|
||||
|
||||
// t0 is the fixed clock these tests place ops on. Every pairing rule in
|
||||
// moves.go is time-windowed, so "now" would make them unreadable.
|
||||
var t0 = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
// delAt is the timed twin of fakeRemote.del (putAt already lives in
|
||||
// history_test.go).
|
||||
func (f *fakeRemote) delAt(dev, path string, at time.Time) {
|
||||
f.t.Helper()
|
||||
f.append(dev, journal.Op{Kind: journal.KindDelete, Path: path, Time: at})
|
||||
}
|
||||
|
||||
// move is the shape the scanner actually emits: one device, one cycle, the
|
||||
// same blob put at the new path and deleted at the old.
|
||||
func (f *fakeRemote) move(dev, from, to, content string, at time.Time) {
|
||||
f.t.Helper()
|
||||
f.putAt(dev, to, content, at)
|
||||
f.delAt(dev, from, at.Add(time.Second))
|
||||
}
|
||||
|
||||
/* ---- the index ---- */
|
||||
|
||||
// buildIndex is the unit-level path: ops in, index out.
|
||||
func buildIndex(ops ...journal.Op) moveIndex {
|
||||
journal.Sort(ops)
|
||||
return buildMoveIndex(ops)
|
||||
}
|
||||
|
||||
func op(kind, path, blob string, at time.Time, dev string, lamport int64) journal.Op {
|
||||
return journal.Op{
|
||||
Kind: kind, Path: path, Blob: blob, Time: at, Device: dev,
|
||||
Lamport: lamport, Seq: lamport, Size: 4, Mode: 0o644,
|
||||
}
|
||||
}
|
||||
|
||||
func sha(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func TestMoveIndexPairsInEitherJournalOrder(t *testing.T) {
|
||||
b := sha("body")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ops []journal.Op
|
||||
}{
|
||||
{"put-then-delete", []journal.Op{
|
||||
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
||||
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute), "deva", 2),
|
||||
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute+time.Second), "deva", 3),
|
||||
}},
|
||||
{"delete-then-put", []journal.Op{
|
||||
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
||||
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 2),
|
||||
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute+4*time.Second), "deva", 3),
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
idx := buildIndex(tc.ops...)
|
||||
evs := idx["a.md"]
|
||||
if len(evs) != 1 || evs[0].To != "docs/a.md" {
|
||||
t.Fatalf("a.md events = %+v, want one move to docs/a.md", evs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveIndexDeclinesOutsideWindow(t *testing.T) {
|
||||
b := sha("body")
|
||||
idx := buildIndex(
|
||||
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
||||
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 2),
|
||||
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Hour), "deva", 3),
|
||||
)
|
||||
if evs := idx["a.md"]; len(evs) != 1 || evs[0].To != "" {
|
||||
t.Fatalf("a.md events = %+v, want one plain deletion", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveIndexDeclinesAcrossDevices(t *testing.T) {
|
||||
b := sha("body")
|
||||
idx := buildIndex(
|
||||
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
||||
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 2),
|
||||
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute+time.Second), "devb", 3),
|
||||
)
|
||||
if evs := idx["a.md"]; len(evs) != 1 || evs[0].To != "" {
|
||||
t.Fatalf("a.md events = %+v, want one plain deletion", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveIndexAmbiguousIdenticalContent(t *testing.T) {
|
||||
// Two files with the same bytes, one deleted, one created: content
|
||||
// identity cannot say which is which, so nothing pairs.
|
||||
b := sha("body")
|
||||
idx := buildIndex(
|
||||
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
||||
op(journal.KindPut, "b.md", b, t0, "deva", 2),
|
||||
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 3),
|
||||
op(journal.KindDelete, "b.md", "", t0.Add(time.Minute), "deva", 4),
|
||||
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute+time.Second), "deva", 5),
|
||||
)
|
||||
for _, p := range []string{"a.md", "b.md"} {
|
||||
if evs := idx[p]; len(evs) != 1 || evs[0].To != "" {
|
||||
t.Fatalf("%s events = %+v, want a plain deletion (ambiguous)", p, evs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveIndexIgnoresOverwritingPut(t *testing.T) {
|
||||
// docs/a.md already existed, so the put that happens to carry a.md's
|
||||
// blob is an edit, not a move destination.
|
||||
b := sha("body")
|
||||
idx := buildIndex(
|
||||
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
||||
op(journal.KindPut, "docs/a.md", sha("other"), t0, "deva", 2),
|
||||
op(journal.KindDelete, "a.md", "", t0.Add(time.Minute), "deva", 3),
|
||||
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Minute), "deva", 4),
|
||||
)
|
||||
if evs := idx["a.md"]; len(evs) != 1 || evs[0].To != "" {
|
||||
t.Fatalf("a.md events = %+v, want a plain deletion", evs)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- resolvers ---- */
|
||||
|
||||
func TestResolveShareDeleteBeforeMove(t *testing.T) {
|
||||
// a.md is deleted, an unrelated a.md is created at the same address,
|
||||
// and THAT one moves away. A share minted before the delete is a promise
|
||||
// about the first file — which is gone.
|
||||
b, c := sha("first"), sha("second")
|
||||
idx := buildIndex(
|
||||
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
||||
op(journal.KindDelete, "a.md", "", t0.Add(time.Hour), "deva", 2),
|
||||
op(journal.KindPut, "a.md", c, t0.Add(2*time.Hour), "deva", 3),
|
||||
op(journal.KindPut, "docs/a.md", c, t0.Add(3*time.Hour), "deva", 4),
|
||||
op(journal.KindDelete, "a.md", "", t0.Add(3*time.Hour+time.Second), "deva", 5),
|
||||
)
|
||||
files := map[string]FileInfo{"docs/a.md": {Blob: c}}
|
||||
if to, ok := resolveShare(idx, files, "a.md", t0); ok {
|
||||
t.Fatalf("share minted before the delete resolved to %q, want gone", to)
|
||||
}
|
||||
to, ok := resolveShare(idx, files, "a.md", t0.Add(2*time.Hour+time.Minute))
|
||||
if !ok || to != "docs/a.md" {
|
||||
t.Fatalf("share minted after the recreate = %q %v, want docs/a.md", to, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveForwardCycle(t *testing.T) {
|
||||
// A journal is peer JSON and can describe A→B→A. Answer "not found"
|
||||
// rather than walk forever.
|
||||
idx := moveIndex{
|
||||
"a.md": {{At: t0, To: "b.md", ToAt: t0}},
|
||||
"b.md": {{At: t0.Add(time.Minute), To: "a.md", ToAt: t0.Add(time.Minute)}},
|
||||
}
|
||||
if to, ok := resolveForward(idx, map[string]FileInfo{}, "a.md"); ok {
|
||||
t.Fatalf("cycle resolved to %q, want not found", to)
|
||||
}
|
||||
if to, ok := resolveShare(idx, map[string]FileInfo{}, "a.md", time.Time{}); ok {
|
||||
t.Fatalf("cycle resolved (share) to %q, want not found", to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainSegmentsExcludeSuccessorAtOldPath(t *testing.T) {
|
||||
b := sha("body")
|
||||
idx := buildIndex(
|
||||
op(journal.KindPut, "a.md", b, t0, "deva", 1),
|
||||
op(journal.KindPut, "docs/a.md", b, t0.Add(time.Hour), "deva", 2),
|
||||
op(journal.KindDelete, "a.md", "", t0.Add(time.Hour+time.Second), "deva", 3),
|
||||
op(journal.KindPut, "a.md", sha("unrelated"), t0.Add(2*time.Hour), "deva", 4),
|
||||
)
|
||||
segs := chainSegments(idx, "docs/a.md")
|
||||
if !inSegments(segs, "a.md", t0) {
|
||||
t.Error("the original a.md version is not in docs/a.md's chain")
|
||||
}
|
||||
if inSegments(segs, "a.md", t0.Add(2*time.Hour)) {
|
||||
t.Error("the unrelated later a.md leaked into docs/a.md's chain")
|
||||
}
|
||||
if !inSegments(segs, "docs/a.md", t0.Add(time.Hour)) {
|
||||
t.Error("docs/a.md's own creating op is not in its chain")
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- viewer ---- */
|
||||
|
||||
func TestMovedFileRedirects(t *testing.T) {
|
||||
f := newFakeRemote(t)
|
||||
f.putAt("deva", "a.md", "# Body", t0)
|
||||
f.move("deva", "a.md", "docs/a.md", "# Body", t0.Add(time.Hour))
|
||||
h := f.server().Handler()
|
||||
|
||||
rec := get(t, h, "/api/file?path=a.md")
|
||||
if rec.Code != 200 || rec.Body.String() != "# Body" {
|
||||
t.Fatalf("moved file: %d %q", rec.Code, rec.Body)
|
||||
}
|
||||
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "docs/a.md" {
|
||||
t.Fatalf("canonical header = %q, want docs/a.md", got)
|
||||
}
|
||||
// The destination itself carries no header — nothing moved.
|
||||
rec = get(t, h, "/api/file?path=docs/a.md")
|
||||
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "" {
|
||||
t.Fatalf("canonical header on the live path = %q, want none", got)
|
||||
}
|
||||
// A path that never existed still 404s.
|
||||
if rec := get(t, h, "/api/file?path=nope.md"); rec.Code != 404 {
|
||||
t.Fatalf("unknown path: %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLivePathWinsOverRedirect(t *testing.T) {
|
||||
f := newFakeRemote(t)
|
||||
f.putAt("deva", "a.md", "# Body", t0)
|
||||
f.move("deva", "a.md", "docs/a.md", "# Body", t0.Add(time.Hour))
|
||||
f.putAt("deva", "a.md", "# Brand new", t0.Add(2*time.Hour))
|
||||
h := f.server().Handler()
|
||||
|
||||
rec := get(t, h, "/api/file?path=a.md")
|
||||
if rec.Code != 200 || rec.Body.String() != "# Brand new" {
|
||||
t.Fatalf("live path: %d %q, want the new file", rec.Code, rec.Body)
|
||||
}
|
||||
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "" {
|
||||
t.Fatalf("canonical header = %q, want none (nothing moved out of a live path)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveChainResolvesInOneHop(t *testing.T) {
|
||||
f := newFakeRemote(t)
|
||||
f.putAt("deva", "a.md", "body", t0)
|
||||
f.move("deva", "a.md", "b.md", "body", t0.Add(time.Hour))
|
||||
f.move("deva", "b.md", "c.md", "body", t0.Add(2*time.Hour))
|
||||
h := f.server().Handler()
|
||||
|
||||
rec := get(t, h, "/api/file?path=a.md")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("a.md: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "c.md" {
|
||||
t.Fatalf("canonical header = %q, want c.md", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFollowsMove(t *testing.T) {
|
||||
f := newFakeRemote(t)
|
||||
f.putAt("deva", "a.md", "# Title", t0)
|
||||
f.move("deva", "a.md", "docs/a.md", "# Title", t0.Add(time.Hour))
|
||||
h := f.server().Handler()
|
||||
|
||||
rec := get(t, h, "/api/render?path=a.md")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("render: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var doc struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.Path != "docs/a.md" {
|
||||
t.Fatalf("rendered path = %q, want the canonical docs/a.md", doc.Path)
|
||||
}
|
||||
if got := rec.Header().Get("X-Bdrive-Canonical-Path"); got != "docs/a.md" {
|
||||
t.Fatalf("canonical header = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- /resolve ---- */
|
||||
|
||||
func TestResolveEndpoint(t *testing.T) {
|
||||
f := newFakeRemote(t)
|
||||
f.putAt("deva", "notes/one.md", "one", t0)
|
||||
f.putAt("deva", "notes/two.md", "two", t0)
|
||||
f.move("deva", "notes/one.md", "wiki/one.md", "one", t0.Add(time.Hour))
|
||||
f.move("deva", "notes/two.md", "wiki/two.md", "two", t0.Add(time.Hour))
|
||||
h := f.server().Handler()
|
||||
|
||||
var got struct{ To, Kind string }
|
||||
rec := get(t, h, "/api/resolve?path=notes")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("folder resolve: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.To != "wiki" || got.Kind != "folder" {
|
||||
t.Fatalf("folder resolve = %+v, want wiki/folder", got)
|
||||
}
|
||||
|
||||
rec = get(t, h, "/api/resolve?path=notes/one.md")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("file resolve: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.To != "wiki/one.md" || got.Kind != "file" {
|
||||
t.Fatalf("file resolve = %+v", got)
|
||||
}
|
||||
|
||||
// A live path has not gone anywhere.
|
||||
if rec := get(t, h, "/api/resolve?path=wiki/one.md"); rec.Code != 404 {
|
||||
t.Fatalf("live path resolve: %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFolderIsAllOrNothing(t *testing.T) {
|
||||
t.Run("half moved", func(t *testing.T) {
|
||||
f := newFakeRemote(t)
|
||||
f.putAt("deva", "notes/one.md", "one", t0)
|
||||
f.putAt("deva", "notes/two.md", "two", t0)
|
||||
f.move("deva", "notes/one.md", "wiki/one.md", "one", t0.Add(time.Hour))
|
||||
if rec := get(t, f.server().Handler(), "/api/resolve?path=notes"); rec.Code != 404 {
|
||||
t.Fatalf("partial move: %d, want 404 (notes/ still exists)", rec.Code)
|
||||
}
|
||||
})
|
||||
t.Run("one deleted", func(t *testing.T) {
|
||||
f := newFakeRemote(t)
|
||||
f.putAt("deva", "notes/one.md", "one", t0)
|
||||
f.putAt("deva", "notes/two.md", "two", t0)
|
||||
f.move("deva", "notes/one.md", "wiki/one.md", "one", t0.Add(time.Hour))
|
||||
f.delAt("deva", "notes/two.md", t0.Add(2*time.Hour))
|
||||
if rec := get(t, f.server().Handler(), "/api/resolve?path=notes"); rec.Code != 404 {
|
||||
t.Fatalf("deleted member: %d, want 404 (no honest destination)", rec.Code)
|
||||
}
|
||||
})
|
||||
t.Run("split destinations", func(t *testing.T) {
|
||||
f := newFakeRemote(t)
|
||||
f.putAt("deva", "notes/one.md", "one", t0)
|
||||
f.putAt("deva", "notes/two.md", "two", t0)
|
||||
f.move("deva", "notes/one.md", "wiki/one.md", "one", t0.Add(time.Hour))
|
||||
f.move("deva", "notes/two.md", "docs/two.md", "two", t0.Add(time.Hour))
|
||||
if rec := get(t, f.server().Handler(), "/api/resolve?path=notes"); rec.Code != 404 {
|
||||
t.Fatalf("split destinations: %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* ---- read heat lands on the canonical path ---- */
|
||||
|
||||
func TestRedirectedReadCreditsCanonicalPath(t *testing.T) {
|
||||
srv, p, root := newHub(t, true, nil)
|
||||
var err error
|
||||
if srv.Reads, err = OpenReadLedger(filepath.Join(t.TempDir(), "reads.json"), 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
||||
f.putAt("deva", "a.md", "# Body", t0)
|
||||
f.move("deva", "a.md", "docs/a.md", "# Body", t0.Add(time.Hour))
|
||||
h := srv.Handler()
|
||||
|
||||
if rec := do(t, h, "GET", "/api/p/"+p.ID+"/file?path=a.md", nil); rec.Code != 200 {
|
||||
t.Fatalf("read: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec := do(t, h, "GET", "/api/p/"+p.ID+"/heat?days=30", nil)
|
||||
var heat struct {
|
||||
Entries map[string]HeatEntry `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &heat); err != nil {
|
||||
t.Fatalf("heat: %v (%s)", err, rec.Body)
|
||||
}
|
||||
if heat.Entries["docs/a.md"].Human == 0 {
|
||||
t.Errorf("heat credited %v, want the read on docs/a.md", heat.Entries)
|
||||
}
|
||||
if heat.Entries["a.md"].Human != 0 {
|
||||
t.Errorf("heat credited the OLD path a.md: %v", heat.Entries)
|
||||
}
|
||||
}
|
||||
@@ -51,11 +51,18 @@ func (s *Server) handleRestore(v *volume, w http.ResponseWriter, r *http.Request
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// The sha must be a version OF THIS PATH: without this, restore would
|
||||
// paste any blob in the store onto any path.
|
||||
// The sha must be a version OF THIS FILE: without this, restore would
|
||||
// paste any blob in the store onto any path. "This file" includes the
|
||||
// paths it lived at before it moved — otherwise a moved file can never
|
||||
// be restored to anything older than its move. Ancestors only: the put
|
||||
// is written at the current path, so a descendant's versions are not
|
||||
// reachable from here anyway. buildMoveIndex needs journal order.
|
||||
journal.Sort(all)
|
||||
chain := chainSegments(buildMoveIndex(all), p)
|
||||
var found *journal.Op
|
||||
for i := range all {
|
||||
if op := &all[i]; op.Kind == journal.KindPut && op.Path == p && op.Blob == req.SHA {
|
||||
op := &all[i]
|
||||
if op.Kind == journal.KindPut && op.Blob == req.SHA && inSegments(chain, op.Path, op.Time) {
|
||||
found = op
|
||||
break
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/journal"
|
||||
)
|
||||
@@ -276,3 +277,70 @@ func TestRestoreNotOnSingleVolume(t *testing.T) {
|
||||
t.Fatalf("single-volume restore: %d, want no such route", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A moved file keeps its past. Without the chain, restore refuses every
|
||||
// version written before the move — the file's own history becomes
|
||||
// unreachable the moment someone drags it into a folder.
|
||||
func TestRestoreReachesVersionsFromBeforeAMove(t *testing.T) {
|
||||
srv, p, root := newHub(t, true, nil)
|
||||
dir := filepath.Join(root, p.ID)
|
||||
f := newFakeRemoteAt(t, dir)
|
||||
at := time.Now().Add(-time.Hour)
|
||||
f.putAt("dev1", "plan.md", "v1", at)
|
||||
f.putAt("dev1", "plan.md", "v2 longer", at.Add(time.Minute))
|
||||
// the move: same device, same blob, one cycle
|
||||
f.putAt("dev1", "notes/plan.md", "v2 longer", at.Add(2*time.Minute))
|
||||
f.delAt("dev1", "plan.md", at.Add(2*time.Minute+time.Second))
|
||||
// an unrelated file later takes the old address
|
||||
f.putAt("dev1", "plan.md", "not the same file", at.Add(time.Hour))
|
||||
h := srv.Handler()
|
||||
base := "/api/p/" + p.ID + "/"
|
||||
|
||||
rec := do(t, h, "POST", base+"restore",
|
||||
map[string]string{"path": "notes/plan.md", "sha": shaOf("v1")})
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("restore across a move: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if rec := do(t, h, "GET", base+"file?path=notes/plan.md", nil); rec.Body.String() != "v1" {
|
||||
t.Fatalf("file after restore = %q, want v1", rec.Body)
|
||||
}
|
||||
// The successor at the old address is a different file, not an ancestor.
|
||||
rec = do(t, h, "POST", base+"restore",
|
||||
map[string]string{"path": "notes/plan.md", "sha": shaOf("not the same file")})
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("restore of the successor's blob: %d %s, want 404", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// The same chain, on the history feed.
|
||||
func TestHistoryFollowsAMove(t *testing.T) {
|
||||
srv, p, root := newHub(t, true, nil)
|
||||
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
||||
at := time.Now().Add(-time.Hour)
|
||||
f.putAt("dev1", "plan.md", "v1", at)
|
||||
f.putAt("dev1", "plan.md", "v2", at.Add(time.Minute))
|
||||
f.putAt("dev1", "notes/plan.md", "v2", at.Add(2*time.Minute))
|
||||
f.delAt("dev1", "plan.md", at.Add(2*time.Minute+time.Second))
|
||||
f.putAt("dev1", "plan.md", "unrelated", at.Add(time.Hour))
|
||||
h := srv.Handler()
|
||||
base := "/api/p/" + p.ID + "/"
|
||||
|
||||
entries := historyOf(t, h, base, "notes/plan.md")
|
||||
var blobs []string
|
||||
for _, e := range entries {
|
||||
blobs = append(blobs, e.Blob)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("history = %d entries %v, want 3 (v1, v2, the move)", len(entries), blobs)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, b := range blobs {
|
||||
seen[b] = true
|
||||
}
|
||||
if !seen[shaOf("v1")] {
|
||||
t.Errorf("the pre-move v1 is missing from the feed: %v", blobs)
|
||||
}
|
||||
if seen[shaOf("unrelated")] {
|
||||
t.Errorf("the unrelated file at the old address leaked in: %v", blobs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,18 @@ type volume struct {
|
||||
|
||||
type snapshot struct {
|
||||
files map[string]FileInfo
|
||||
// moves is the derived rename index (see moves.go), cached with the
|
||||
// listing it was replayed alongside. Nil for a source that has no
|
||||
// journals to derive it from — every resolver then answers "not found",
|
||||
// so the DirSource exclusion falls out instead of needing a rule.
|
||||
moves moveIndex
|
||||
}
|
||||
|
||||
// MoveSource is a Source that can also report where its files came from.
|
||||
// Optional, like Uploader: implementing it keeps the replay ONE pass, so the
|
||||
// move index costs no extra journal read.
|
||||
type MoveSource interface {
|
||||
FilesWithMoves(context.Context) (map[string]FileInfo, moveIndex, error)
|
||||
}
|
||||
|
||||
func (v *volume) snapshot(ctx context.Context) (*snapshot, error) {
|
||||
@@ -211,14 +223,23 @@ func (v *volume) snapshot(ctx context.Context) (*snapshot, error) {
|
||||
if v.snap != nil && time.Since(v.at) < v.refresh {
|
||||
return v.snap, nil
|
||||
}
|
||||
files, err := v.source.Files(ctx)
|
||||
var (
|
||||
files map[string]FileInfo
|
||||
moves moveIndex
|
||||
err error
|
||||
)
|
||||
if ms, ok := v.source.(MoveSource); ok {
|
||||
files, moves, err = ms.FilesWithMoves(ctx)
|
||||
} else {
|
||||
files, err = v.source.Files(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
if v.snap != nil {
|
||||
return v.snap, nil // serve stale rather than fail
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
v.snap, v.at = &snapshot{files: files}, time.Now()
|
||||
v.snap, v.at = &snapshot{files: files, moves: moves}, time.Now()
|
||||
return v.snap, nil
|
||||
}
|
||||
|
||||
@@ -468,9 +489,16 @@ func (r *RemoteSource) loadSourcedOps(ctx context.Context) ([]sourcedOp, error)
|
||||
}
|
||||
|
||||
func (r *RemoteSource) Files(ctx context.Context) (map[string]FileInfo, error) {
|
||||
files, _, err := r.FilesWithMoves(ctx)
|
||||
return files, err
|
||||
}
|
||||
|
||||
// FilesWithMoves is the replay, plus the rename index derived from the same
|
||||
// sorted ops — one pass, so the index rides in the cached snapshot.
|
||||
func (r *RemoteSource) FilesWithMoves(ctx context.Context) (map[string]FileInfo, moveIndex, error) {
|
||||
all, err := r.loadOps(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
journal.Sort(all)
|
||||
files := make(map[string]FileInfo)
|
||||
@@ -495,7 +523,7 @@ func (r *RemoteSource) Files(ctx context.Context) (map[string]FileInfo, error) {
|
||||
delete(files, op.Path)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
return files, buildMoveIndex(all), nil
|
||||
}
|
||||
|
||||
func (r *RemoteSource) Open(ctx context.Context, _ string, fi FileInfo) (io.ReadCloser, error) {
|
||||
@@ -567,6 +595,7 @@ func (s *Server) Handler() http.Handler {
|
||||
"/api/p/{project}/": proj,
|
||||
} {
|
||||
mux.HandleFunc("GET "+prefix+"tree", resolve(PermRead, s.handleTree))
|
||||
mux.HandleFunc("GET "+prefix+"resolve", resolve(PermRead, s.handleResolve))
|
||||
mux.HandleFunc("GET "+prefix+"file", resolve(PermRead, s.handleFile))
|
||||
mux.HandleFunc("GET "+prefix+"download", resolve(PermRead, s.handleDownload))
|
||||
mux.HandleFunc("GET "+prefix+"render", resolve(PermRead, s.handleRender))
|
||||
@@ -1008,7 +1037,18 @@ func lookup(v *volume, r *http.Request) (string, FileInfo, int, error) {
|
||||
}
|
||||
fi, ok := snap.files[p]
|
||||
if !ok {
|
||||
return "", FileInfo{}, http.StatusNotFound, fmt.Errorf("no such file: %s", p)
|
||||
// The address is empty — but the file may have moved out of it. A
|
||||
// LIVE path always wins, which falls out of the ordering: the
|
||||
// snapshot hit above returns first, so nothing redirects while
|
||||
// something still answers at the old address.
|
||||
to, moved := resolveForward(snap.moves, snap.files, p)
|
||||
if !moved {
|
||||
return "", FileInfo{}, http.StatusNotFound, fmt.Errorf("no such file: %s", p)
|
||||
}
|
||||
// The canonical path is what gets returned, so the read is recorded
|
||||
// against it (heat doesn't split across old and new) and the render
|
||||
// payload names it.
|
||||
p, fi = to, snap.files[to]
|
||||
}
|
||||
return p, fi, 0, nil
|
||||
}
|
||||
@@ -1019,6 +1059,7 @@ func (s *Server) serveBlob(v *volume, w http.ResponseWriter, r *http.Request, at
|
||||
http.Error(w, err.Error(), code)
|
||||
return
|
||||
}
|
||||
setCanonical(w, r, p)
|
||||
// Count the read before the ETag check: a 304 render is still a person
|
||||
// reading the file, and skipping it would undercount the hottest pages.
|
||||
s.recordRead(r, p)
|
||||
@@ -1086,6 +1127,7 @@ func (s *Server) handleRender(v *volume, w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, err.Error(), code)
|
||||
return
|
||||
}
|
||||
setCanonical(w, r, p)
|
||||
s.recordRead(r, p)
|
||||
rc, err := v.source.Open(r.Context(), p, fi)
|
||||
if err != nil {
|
||||
|
||||
@@ -66,7 +66,11 @@ func (f *fakeRemote) append(dev string, op journal.Op) {
|
||||
f.lam++
|
||||
f.seq[dev]++
|
||||
op.Seq, op.Lamport = f.seq[dev], f.lam
|
||||
op.Time = time.Now().UTC()
|
||||
// A caller that set Time keeps it: move pairing and share resolution are
|
||||
// both time-windowed, so those tests have to place ops in time.
|
||||
if op.Time.IsZero() {
|
||||
op.Time = time.Now().UTC()
|
||||
}
|
||||
op.Device, op.DeviceName, op.Author = dev, dev, dev+"@test"
|
||||
if err := journal.Append(filepath.Join(f.dir, "journal", dev+".jsonl"), []journal.Op{op}); err != nil {
|
||||
f.t.Fatal(err)
|
||||
|
||||
@@ -458,14 +458,19 @@ func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "content temporarily unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
fi, ok := snap.files[sh.Path]
|
||||
// A share token is a promise about ONE file, so it follows that file
|
||||
// when it moves — the opposite of a viewer URL, which is an address and
|
||||
// always serves whatever lives there now. That also closes a leak: a
|
||||
// share used to serve whatever unrelated file later occupied its path.
|
||||
sp, ok := resolveShare(snap.moves, snap.files, sh.Path, sh.Created)
|
||||
if !ok {
|
||||
http.Error(w, "the shared file no longer exists", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
fi := snap.files[sp]
|
||||
// A share hit is external consumption. Actor is token+IP: one audience
|
||||
// member reloading is debounced to a visit, distinct visitors still count.
|
||||
s.Reads.Record(sh.Project, sh.Path, ReadKindShare, sh.Token+"/"+s.clientIP(r))
|
||||
s.Reads.Record(sh.Project, sp, ReadKindShare, sh.Token+"/"+s.clientIP(r))
|
||||
|
||||
// Share links are the only unauthenticated door to stored bytes, so they
|
||||
// are the only egress a plan actually caps. The per-IP limiter above
|
||||
@@ -485,7 +490,7 @@ func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
|
||||
cw := &countingWriter{w: w}
|
||||
defer func() { s.quota().RecordEgress(org, cw.n) }()
|
||||
|
||||
rc, err := v.source.Open(r.Context(), sh.Path, fi)
|
||||
rc, err := v.source.Open(r.Context(), sp, fi)
|
||||
if err != nil {
|
||||
http.Error(w, "content temporarily unavailable", http.StatusBadGateway)
|
||||
return
|
||||
@@ -493,13 +498,13 @@ func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
|
||||
defer rc.Close()
|
||||
|
||||
if r.URL.Query().Get("download") == "1" {
|
||||
w.Header().Set("Content-Type", contentType(sh.Path))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", sanitizeFilename(path.Base(sh.Path))))
|
||||
w.Header().Set("Content-Type", contentType(sp))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", sanitizeFilename(path.Base(sp))))
|
||||
io.Copy(cw, rc)
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.ToLower(path.Ext(sh.Path)) {
|
||||
switch strings.ToLower(path.Ext(sp)) {
|
||||
case ".md", ".markdown":
|
||||
src, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
@@ -512,12 +517,12 @@ func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(cw, sharedMarkdownShell, html.EscapeString(path.Base(sh.Path)), updatedStamp(fi.Time), body)
|
||||
fmt.Fprintf(cw, sharedMarkdownShell, html.EscapeString(path.Base(sp)), updatedStamp(fi.Time), body)
|
||||
case ".html", ".htm":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
io.Copy(cw, rc)
|
||||
default:
|
||||
w.Header().Set("Content-Type", contentType(sh.Path))
|
||||
w.Header().Set("Content-Type", contentType(sp))
|
||||
setContentLength(w, rc) // measured, never the journal's Size field
|
||||
io.Copy(cw, rc)
|
||||
}
|
||||
|
||||
@@ -667,3 +667,58 @@ func authAs(t *testing.T, srv *Server, req *http.Request) {
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
|
||||
// A share token is a promise about ONE file. These three pin the direction
|
||||
// it goes when a path changes hands — the opposite of the viewer's, which is
|
||||
// an address and always serves whatever lives there now.
|
||||
|
||||
func TestShareFollowsMovedFile(t *testing.T) {
|
||||
srv, p, _, f, h := shareHub(t)
|
||||
token, _ := authedShare(t, srv, h, p.ID, "wiki/notes.md")
|
||||
|
||||
at := time.Now().Add(time.Minute)
|
||||
f.putAt("dev1", "docs/notes.md", "# Notes\n\nhello **team**", at)
|
||||
f.delAt("dev1", "wiki/notes.md", at.Add(time.Second))
|
||||
|
||||
rec := do(t, h, "GET", "/s/"+token, nil)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "hello") {
|
||||
t.Fatalf("share after move: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
// ...and keeps following it once an UNRELATED file takes the old address.
|
||||
f.putAt("dev1", "wiki/notes.md", "# Someone else's file", at.Add(time.Hour))
|
||||
rec = do(t, h, "GET", "/s/"+token, nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("share with a new file at the old path: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if body := rec.Body.String(); strings.Contains(body, "Someone else") {
|
||||
t.Fatalf("share served the file that took its old address:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareOfDeletedFileNeverServesItsSuccessor(t *testing.T) {
|
||||
srv, p, _, f, h := shareHub(t)
|
||||
token, _ := authedShare(t, srv, h, p.ID, "wiki/notes.md")
|
||||
|
||||
at := time.Now().Add(time.Minute)
|
||||
f.delAt("dev1", "wiki/notes.md", at)
|
||||
if rec := do(t, h, "GET", "/s/"+token, nil); rec.Code != 404 {
|
||||
t.Fatalf("share of a deleted file: %d, want 404", rec.Code)
|
||||
}
|
||||
// The leak this closes: something new lands on the address later, and
|
||||
// the public link used to start serving it with no revoke and no signal.
|
||||
f.putAt("dev1", "wiki/notes.md", "# Payroll", at.Add(time.Hour))
|
||||
rec := do(t, h, "GET", "/s/"+token, nil)
|
||||
if rec.Code != 404 {
|
||||
t.Fatalf("share resurrected by an unrelated file: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareUnaffectedByAnUnmovedFile(t *testing.T) {
|
||||
srv, p, _, f, h := shareHub(t)
|
||||
_ = f
|
||||
token, _ := authedShare(t, srv, h, p.ID, "wiki/notes.md")
|
||||
if rec := do(t, h, "GET", "/s/"+token, nil); rec.Code != 200 {
|
||||
t.Fatalf("unmoved share: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-BiaGRL-i.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-u2Ih5Asg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BdCy9HmN.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -28,6 +28,12 @@ bdrive url --sync wiki/report.html
|
||||
|
||||
With no argument, `bdrive url` gives the project home.
|
||||
|
||||
If someone later renames the file or drags it into a folder, the old URL keeps
|
||||
working: the hub pairs the rename's two halves in the journal and redirects to
|
||||
the file's new home, saying "Moved from …" above the content. A **live** path
|
||||
always wins, though — if a brand-new file has since taken the old address, that
|
||||
new file is what the URL serves, with no redirect.
|
||||
|
||||
:::tip[Agents do this automatically]
|
||||
The sync hook `bdrive init` registers injects the project's
|
||||
gated-link formula into the agent's context, so a connected agent appends
|
||||
@@ -50,6 +56,12 @@ https://drive.example.com/s/eacc1df3ee6a6ebbdacc535c2796dc30
|
||||
Links serve the file's **latest** synced content, which is the right behavior
|
||||
for living reports and wiki pages, and live until they expire or you revoke them.
|
||||
|
||||
A share link points at one **file**, not at an address — the opposite of an
|
||||
internal link. Move or rename the file and the link follows it, even if a new
|
||||
file later takes the old path. Delete the file and the link 404s, and stays
|
||||
404 forever: nothing that later appears at that path is ever served through a
|
||||
link minted for something else.
|
||||
|
||||
```sh
|
||||
bdrive share --list # every link you've minted
|
||||
bdrive share --revoke <token-or-url> # kill one
|
||||
|
||||
Reference in New Issue
Block a user