mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): remove browser upload UI — content enters via local sync only (for now)
Topbar button, ⋯ menu entry, palette action, hidden file input, and the upload plumbing (upload.ts) are gone; the server upload API stays (devices and the store proxy depend on it). Spec reworked to seed via API and assert the affordance is absent. 44/44 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
This commit is contained in:
co-authored by
Claude Fable 5
parent
40a0b19aa4
commit
51ce477b07
@@ -113,17 +113,20 @@ test("share mints a public link that serves the file, revoke kills it", async ({
|
||||
expect(gone.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("upload into the selected folder, then the file opens", async ({ page }) => {
|
||||
test("no browser upload: content arrives via sync; the tree picks it up", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/notes`);
|
||||
await page.locator("#upload-btn").waitFor();
|
||||
await page.setInputFiles('input[type="file"]', {
|
||||
name: "dropped.md",
|
||||
mimeType: "text/markdown",
|
||||
buffer: Buffer.from("# Dropped\n\nUploaded through the browser.\n"),
|
||||
});
|
||||
await page.waitForURL(`/${pid}/notes/dropped.md`);
|
||||
// The upload affordance is gone everywhere — content enters via local sync.
|
||||
await expect(page.locator("#upload-btn")).toHaveCount(0);
|
||||
await expect(page.locator('input[type="file"]')).toHaveCount(0);
|
||||
// A file lands through the device/store path (simulated via the API)…
|
||||
await page.request.put(
|
||||
`/api/p/${pid}/upload/content?path=${encodeURIComponent("notes/dropped.md")}`,
|
||||
{ data: "# Dropped\n\nArrived through sync.\n" },
|
||||
);
|
||||
// …and the polling tree shows it; opening renders it.
|
||||
await page.goto(`/${pid}/notes/dropped.md`);
|
||||
await expect(page.locator("#content h1")).toHaveText("Dropped");
|
||||
await expect(page.locator('#tree .row[data-path="notes/dropped.md"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { Project, ServerConfig } from "../api/types";
|
||||
import { useHeat, useTree } from "../hooks/useBrowse";
|
||||
import { urlForPath, urlForView, type Route } from "../router";
|
||||
import { currentNavType, navigate, useLocationPath } from "../nav";
|
||||
import { uploadFile } from "../upload";
|
||||
import { copyText } from "../util";
|
||||
import { toast } from "../toast";
|
||||
import { AppShell, Icon, Topbar, closeSidebarOnMobile } from "../components/shell";
|
||||
@@ -138,17 +137,16 @@ export default function Browser(props: {
|
||||
|
||||
/* ---- topbar state + actions ---- */
|
||||
const [meta, setMeta] = useState("");
|
||||
const [uploadStatus, setUploadStatus] = useState("");
|
||||
const [share, setShare] = useState<{ url: string; copied: boolean } | null>(null);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const uploadInput = useRef<HTMLInputElement>(null);
|
||||
const downloadRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const panel = props.panel ?? null;
|
||||
const canShare = !panel && hub && !!project && isFile;
|
||||
const canHistory = !panel && hub && !!project;
|
||||
const canUpload = !!config.upload?.enabled && (!hub || !!project);
|
||||
// Browser upload is deliberately absent (for now): content enters through
|
||||
// local sync only; the web app is a read/share/history surface.
|
||||
const canDownload = !panel && isFile;
|
||||
const canMore = !panel && (isFile || (hub && !!project && isDir));
|
||||
const downloadURL = apiBase + "download?path=" + encodeURIComponent(path);
|
||||
@@ -175,32 +173,6 @@ export default function Browser(props: {
|
||||
openHistory(isDir ? path + "/" : path);
|
||||
}, [path, isDir, openHistory]);
|
||||
|
||||
const uploadNow = useCallback(() => uploadInput.current?.click(), []);
|
||||
const onUploadPick = async () => {
|
||||
const input = uploadInput.current!;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
// A selected folder receives the upload; a selected file means "next
|
||||
// to it".
|
||||
const dir = !path ? "" : isDir ? path : path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
||||
const dest = dir ? dir + "/" + file.name : file.name;
|
||||
try {
|
||||
setUploadStatus(`Uploading ${dest}…`);
|
||||
await uploadFile(apiBase, dest, file);
|
||||
setUploadStatus(`Uploaded ${dest}`);
|
||||
await qc.invalidateQueries({ queryKey: ["tree", apiBase] });
|
||||
openPath(dest);
|
||||
} catch (err) {
|
||||
setUploadStatus("Upload failed: " + (err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Any navigation clears a stale upload status from the meta slot.
|
||||
setUploadStatus("");
|
||||
}, [routeKey]);
|
||||
|
||||
/* ---- ⌘K palette ---- */
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -223,7 +195,6 @@ export default function Browser(props: {
|
||||
if (isFile) add("download", "Download: " + path, "action", () => downloadRef.current?.click());
|
||||
}
|
||||
if (hub && project) add("hist", "History: whole project", "action", () => openHistory(""));
|
||||
if (canUpload) add("upload", "Upload a file…", "action", uploadNow);
|
||||
if (hub) {
|
||||
for (const p of props.projects || []) {
|
||||
if (!project || p.id !== project.id) {
|
||||
@@ -237,7 +208,7 @@ export default function Browser(props: {
|
||||
for (const d of dirIndex.keys()) add("folder", d, "folder", () => openPath(d));
|
||||
for (const f of flatFiles) add("doc", f.path, "file", () => openPath(f.path));
|
||||
return items;
|
||||
}, [hub, project, path, isFile, canUpload, config.auth?.enabled, dirIndex, flatFiles, props.projects, shareNow, historyNow, uploadNow, openHistory, openPath]);
|
||||
}, [hub, project, path, isFile, config.auth?.enabled, dirIndex, flatFiles, props.projects, shareNow, historyNow, openHistory, openPath]);
|
||||
|
||||
/* ---- "⋯ More" menu (secondary actions on narrow screens) ---- */
|
||||
useEffect(() => {
|
||||
@@ -372,7 +343,7 @@ export default function Browser(props: {
|
||||
const topbar = (
|
||||
<Topbar
|
||||
crumb={crumb}
|
||||
meta={uploadStatus || meta}
|
||||
meta={meta}
|
||||
actions={
|
||||
<>
|
||||
<button id="search-btn" className="btn ghost" title="Search (⌘K)" onClick={() => setPaletteOpen(true)}>
|
||||
@@ -388,12 +359,6 @@ export default function Browser(props: {
|
||||
<Icon name="hist" /> <span className="lbl">History</span>
|
||||
</button>
|
||||
)}
|
||||
{canUpload && (
|
||||
<button id="upload-btn" className="btn" onClick={uploadNow}>
|
||||
<Icon name="upload" /> <span className="lbl">Upload</span>
|
||||
</button>
|
||||
)}
|
||||
<input type="file" hidden ref={uploadInput} onChange={onUploadPick} />
|
||||
{canDownload && (
|
||||
<a id="download" className="btn" download href={downloadURL} ref={downloadRef}>
|
||||
<Icon name="download" /> <span className="lbl">Download</span>
|
||||
@@ -420,11 +385,6 @@ export default function Browser(props: {
|
||||
History
|
||||
</button>
|
||||
)}
|
||||
{canUpload && (
|
||||
<button className="more-item" onClick={uploadNow}>
|
||||
Upload
|
||||
</button>
|
||||
)}
|
||||
{canDownload && (
|
||||
<button className="more-item" onClick={() => downloadRef.current?.click()}>
|
||||
Download
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { UploadPlan } from "./api/types";
|
||||
|
||||
/* The client asks the server how to upload (upload/init): "direct" hands
|
||||
back a short-lived presigned URL and the bytes go straight to the object
|
||||
store; "server" means relay the bytes through the bdrive server. */
|
||||
export async function uploadFile(apiBase: string, dest: string, file: File): Promise<void> {
|
||||
const buf = await file.arrayBuffer();
|
||||
const sha = await sha256Hex(buf);
|
||||
const post = async (url: string, body: unknown) => {
|
||||
const r = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json();
|
||||
};
|
||||
const req = { path: dest, sha256: sha, size: file.size };
|
||||
const plan: UploadPlan = await post(apiBase + "upload/init", req);
|
||||
if (plan.mode === "direct") {
|
||||
if (!plan.exists) {
|
||||
// identical content already in the store? skip the PUT
|
||||
const r = await fetch(plan.url!, {
|
||||
method: plan.method || "PUT",
|
||||
headers: plan.headers || {},
|
||||
body: buf,
|
||||
});
|
||||
if (!r.ok) throw new Error("storage upload failed: " + r.status);
|
||||
}
|
||||
await post(apiBase + "upload/commit", req);
|
||||
} else {
|
||||
const r = await fetch(apiBase + "upload/content?path=" + encodeURIComponent(dest), {
|
||||
method: "PUT",
|
||||
body: buf,
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256Hex(buf: ArrayBuffer): Promise<string> {
|
||||
if (crypto.subtle) {
|
||||
const d = await crypto.subtle.digest("SHA-256", buf);
|
||||
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
return sha256Fallback(new Uint8Array(buf)); // plain-http origins have no crypto.subtle
|
||||
}
|
||||
|
||||
/* Minimal SHA-256 (FIPS 180-4) for non-secure contexts. */
|
||||
function sha256Fallback(bytes: Uint8Array): string {
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]);
|
||||
const H = new Uint32Array([
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]);
|
||||
const rr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
|
||||
const len = bytes.length;
|
||||
const padded = new Uint8Array(((((len + 8) >> 6) + 1) << 6));
|
||||
padded.set(bytes);
|
||||
padded[len] = 0x80;
|
||||
const dv = new DataView(padded.buffer);
|
||||
dv.setUint32(padded.length - 8, Math.floor((len * 8) / 0x100000000));
|
||||
dv.setUint32(padded.length - 4, (len * 8) >>> 0);
|
||||
const w = new Uint32Array(64);
|
||||
for (let off = 0; off < padded.length; off += 64) {
|
||||
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = rr(w[i - 15], 7) ^ rr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
||||
const s1 = rr(w[i - 2], 17) ^ rr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
||||
}
|
||||
let [a, b, c, d, e, f, g, h] = H as unknown as number[];
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
|
||||
const t1 = (h + S1 + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
|
||||
const S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
|
||||
const t2 = (S0 + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
|
||||
h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
||||
}
|
||||
H[0] += a; H[1] += b; H[2] += c; H[3] += d; H[4] += e; H[5] += f; H[6] += g; H[7] += h;
|
||||
}
|
||||
return [...H].map((x) => (x >>> 0).toString(16).padStart(8, "0")).join("");
|
||||
}
|
||||
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 100 100'><text y='.9em' font-size='90'>🐻</text></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-lt5n9mM7.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-b5pl3IMV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BWf7Qnlg.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user