From acb99e85e339db2544c9689b74e3b051d346c1e9 Mon Sep 17 00:00:00 2001 From: "Snow W. Lee (Sungwon)" Date: Wed, 29 Jul 2026 17:05:06 +0900 Subject: [PATCH] feat(hub): set an expiry on a share link from the UI (BEA-29) (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI has had --expires all along and the Public links table has always said "no expiry" — naming an alternative the hub UI never offered. Every link minted from the browser was permanent. Expiry is offered AFTER minting, not before: the one-click share stays one click, and PATCHing the token we just handed out keeps the URL already on the clipboard valid. Minting with a TTL would instead create a second link, since ShareDB.Create only reuses permanent ones. - ShareDB.SetExpiry re-dates a live share in place; repo-write failure restores the previous row rather than deleting it (unlike Create's rollback, this row already existed — dropping it would revoke a live link over a disk hiccup). - PATCH /api/shares/{token} mirrors handleShareRevoke: resolve the token, requirePerm(PermWrite), act. Duration parsing is copied from handleShareCreate so the two routes can't drift. - Share dialog gets an Expires select (Never / 24h / 7d / 30d). A failed PATCH toasts and reverts the control. Copy link keeps the dialog's initial focus — the new control would otherwise have taken it. Co-authored-by: Claude Opus 5 --- README.md | 4 +- architecture/webapp-server.md | 2 +- internal/webapp/db_conformance_test.go | 7 ++ internal/webapp/frontend/e2e/browse.spec.ts | 26 ++++++ .../frontend/src/components/ShareDialog.tsx | 68 +++++++++++++- .../frontend/src/components/SharesTable.tsx | 8 +- internal/webapp/frontend/src/style.css | 5 + internal/webapp/perms_test.go | 7 ++ internal/webapp/server.go | 1 + internal/webapp/shares.go | 70 ++++++++++++++ internal/webapp/shares_test.go | 91 +++++++++++++++++++ ...{index-B18K6amq.css => index-C4JfQXVN.css} | 2 +- .../{index-D0KboqaF.js => index-D3Zwa1cX.js} | 30 +++--- internal/webapp/static/index.html | 4 +- plugin/skills/beardrive/SKILL.md | 2 +- .../content/docs/guides/agent-artifacts.md | 7 +- web/docs/src/content/docs/reference/cli.md | 2 +- 17 files changed, 309 insertions(+), 27 deletions(-) rename internal/webapp/static/assets/{index-B18K6amq.css => index-C4JfQXVN.css} (88%) rename internal/webapp/static/assets/{index-D0KboqaF.js => index-D3Zwa1cX.js} (75%) diff --git a/README.md b/README.md index c933bb2..abec2d9 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,9 @@ https://drive.example.com/s/eacc1df3ee6a6ebbdacc535c2796dc30 Links always serve the file's **latest** synced content (right for wiki pages and living reports), and live until revoked — `bdrive share --list` and `--revoke ` manage them, `--expires 24h` makes one -self-destruct. The web UI has a Share button on every file. +self-destruct. The web UI has a Share button on every file, and its +dialog can put an expiry on the link it just minted (24 hours, 7 days, +30 days) without changing the URL you already copied. Shared HTML renders as a real page, markdown renders like the viewer (with a small "Shared with BearDrive" footer; raw HTML is served diff --git a/architecture/webapp-server.md b/architecture/webapp-server.md index 2ad8807..ae38a68 100644 --- a/architecture/webapp-server.md +++ b/architecture/webapp-server.md @@ -146,7 +146,7 @@ classDiagram class ShareDB { -repo ShareRepo -byToken - +Create +Get +Revoke + +Create +Get +Revoke +SetExpiry } class Share { +Token +Project +Path +Creator +Expires diff --git a/internal/webapp/db_conformance_test.go b/internal/webapp/db_conformance_test.go index 89e073e..d131652 100644 --- a/internal/webapp/db_conformance_test.go +++ b/internal/webapp/db_conformance_test.go @@ -190,6 +190,10 @@ func TestMetaStoreConformance(t *testing.T) { if !shares.Revoke(gone.Token) { t.Fatal("revoke should succeed") } + dated, _ := shares.Create(p1.ID, "deck.md", "boss@x.io", 0) + if _, ok, err := shares.SetExpiry(dated.Token, time.Hour); err != nil || !ok { + t.Fatalf("set expiry: %v %v", ok, err) + } devices, err := NewDeviceRegistry(st.Devices()) if err != nil { @@ -279,6 +283,9 @@ func TestMetaStoreConformance(t *testing.T) { if _, ok := shares2.Get(gone.Token); ok { t.Fatal("revoked share came back after reload") } + if got, ok := shares2.Get(dated.Token); !ok || got.Expires.IsZero() { + t.Fatalf("patched expiry lost across reload: %+v", got) + } devices2, _ := NewDeviceRegistry(st2.Devices()) d, ok := devices2.Get("d1") diff --git a/internal/webapp/frontend/e2e/browse.spec.ts b/internal/webapp/frontend/e2e/browse.spec.ts index 19e9e66..437144e 100644 --- a/internal/webapp/frontend/e2e/browse.spec.ts +++ b/internal/webapp/frontend/e2e/browse.spec.ts @@ -166,6 +166,32 @@ test("share mints a public link that serves the file, revoke kills it", async ({ expect(gone.status()).toBe(404); }); +// BEA-29: the CLI has had --expires all along; the dialog now offers it on +// the link you just minted, without changing that link's URL. +test("share dialog sets an expiry on the link it just minted", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + await page.goto(`/${pid}/index.md`); + await page.click("#share-btn"); + const url = (await page.locator(".modal-url").textContent())!; + await expect(page.locator(".modal-expiry-note")).toHaveText("no expiry"); + + await page.selectOption("#share-expiry", "168h"); + await expect(page.locator(".modal-expiry-note")).toContainText("expires"); + // Same link: the URL already on the clipboard keeps working. + expect(await page.locator(".modal-url").textContent()).toBe(url); + expect((await page.request.get(url)).status()).toBe(200); + await page.click(".modal button:has-text('Done')"); + + // …and Settings stops calling it permanent. + await page.goto(`/${pid}/settings`); + const row = page.locator(".admin-item", { hasText: "index.md" }); + await expect(row.locator(".ai-tag")).toContainText("expires"); + await expect(row.locator(".ai-tag")).not.toContainText("no expiry"); + + await page.request.delete(`/api/shares/${url.split("/s/")[1]}`); +}); + test("no browser upload: content arrives via sync; the tree picks it up", async ({ page }) => { await login(page); const pid = await wikiId(page); diff --git a/internal/webapp/frontend/src/components/ShareDialog.tsx b/internal/webapp/frontend/src/components/ShareDialog.tsx index 38550cb..e526111 100644 --- a/internal/webapp/frontend/src/components/ShareDialog.tsx +++ b/internal/webapp/frontend/src/components/ShareDialog.tsx @@ -1,9 +1,24 @@ +import { useRef, useState } from "react"; import { api } from "../api/http"; import { Button } from "@/components/ui/button"; import { copyText } from "../util"; import { toast } from "../toast"; +import { expiryLabel } from "./SharesTable"; +import type { ShareInfo } from "../api/types"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +/* Expiry is offered AFTER minting, never before: the one-click share is the + flow this product is fast at, and ~90% of links are permanent. PATCHing the + token we just handed out also keeps the URL already on the clipboard valid — + minting with a TTL would create a second link, since ShareDB.Create only + reuses permanent ones. */ +const EXPIRY_PRESETS = [ + { value: "", label: "Never" }, + { value: "24h", label: "In 24 hours" }, + { value: "168h", label: "In 7 days" }, + { value: "720h", label: "In 30 days" }, +]; + /* A clear, explicitly-public share confirmation: warns that anyone with the link can view, and offers copy / open / revoke. The title says "Public link", not "created": ShareDB.Create hands back the file's existing live @@ -18,19 +33,68 @@ export function ShareDialog({ onClose: () => void; }) { const token = url.split("/s/")[1]; + // The dialog only ever opens on a freshly minted link, which is permanent. + const [preset, setPreset] = useState(""); + const [expires, setExpires] = useState(); + const [busy, setBusy] = useState(false); + // The expiry control now sits above the buttons, so Radix's "focus the + // first tabbable child" would land on it. Copy link keeps the focus: the + // dialog exists to hand over a URL, expiry is the exception. + const copyRef = useRef(null); + + async function setExpiry(next: string) { + const prev = preset; + setPreset(next); + setBusy(true); + try { + const s = await api("PATCH", "/api/shares/" + token, { expires_in: next }); + setExpires(s.expires); + } catch (e) { + // Never leave the control claiming a lifetime the server didn't take. + toast((e as Error).message, true); + setPreset(prev); + } finally { + setBusy(false); + } + } + return ( !open && onClose()}> - + { + e.preventDefault(); + copyRef.current?.focus(); + }} + >

Public link

Anyone with this link can view this file — no account needed. It always shows the - latest version until you revoke it. + latest version until it expires or you revoke it.

{url}
+
+ + + {expiryLabel(expires)} +