mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Replace the frontend's hash-based routing with the History API and real `/` paths, so URLs read like native file paths: /#p-4e61c7d4/shared%2Fidea.md -> /p-4e61c7d4/shared/idea.md /#shared%2Fnotes.md -> /shared/notes.md (volume mode) /#join/<token> -> /join/<token> Client (app.js): - parseRoute() reads location.pathname; pushURL/syncURL push native paths (segments percent-encoded, "/" kept literal); a popstate handler restores back/forward. Invites read from /join/<token>. - All api/ fetches and the app.js/style.css refs are now root-absolute so a deep path doesn't break relative URL resolution. Server (server.go): - New Server.frontend handler: real assets serve directly; every other non-API/auth/share GET returns index.html (SPA fallback), so deep links and refreshes resolve instead of 404ing. Reserved prefixes stay 404s. - Invite links minted as /join/<token> (orgs.go). Tests: TestFrontendSPAFallback covers the fallback + reserved-prefix 404s; existing invite test updated. Verified end to end in the running hub (deep-link reload, back/forward, no %2F/# in the bar). Docs updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
104 lines
3.4 KiB
Go
104 lines
3.4 KiB
Go
package webapp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func dirServer(t *testing.T, files map[string]string) http.Handler {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
for rel, content := range files {
|
|
abs := filepath.Join(root, filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
s := &Server{Source: &DirSource{Root: root}, Volume: "local", Refresh: 0}
|
|
return s.Handler()
|
|
}
|
|
|
|
func TestDirSourceServesFolder(t *testing.T) {
|
|
h := dirServer(t, map[string]string{
|
|
"README.md": "# Local",
|
|
"notes/plan.md": "content",
|
|
".bdrive": `{"volume":"x"}`, // settings file must be hidden
|
|
".git/config": "noise", // .git must be skipped
|
|
})
|
|
|
|
var root Node
|
|
if err := json.Unmarshal(get(t, h, "/api/tree").Body.Bytes(), &root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
names := []string{}
|
|
for _, n := range root.Children {
|
|
names = append(names, n.Name)
|
|
}
|
|
if len(root.Children) != 2 || root.Children[0].Name != "notes" || root.Children[1].Name != "README.md" {
|
|
t.Fatalf("tree children = %v, want [notes README.md]", names)
|
|
}
|
|
|
|
rec := get(t, h, "/api/file?path=notes/plan.md")
|
|
if rec.Code != 200 || rec.Body.String() != "content" {
|
|
t.Fatalf("file: %d %q", rec.Code, rec.Body)
|
|
}
|
|
if rec.Header().Get("ETag") == "" {
|
|
t.Fatal("dir source should still produce ETags")
|
|
}
|
|
|
|
rec = get(t, h, "/api/render?path=README.md")
|
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Local") {
|
|
t.Fatalf("render: %d %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
if rec := get(t, h, "/api/file?path=.git/config"); rec.Code != 404 {
|
|
t.Fatalf(".git content must be hidden, got %d", rec.Code)
|
|
}
|
|
if rec := get(t, h, "/api/file?path=../escape"); rec.Code != 404 {
|
|
t.Fatalf("path traversal must 404, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// The frontend serves real assets directly but returns the app shell for any
|
|
// client-side route (a deep file path, /join/<token>), so a deep link or
|
|
// refresh doesn't 404. Reserved API/auth/share prefixes stay real 404s.
|
|
func TestFrontendSPAFallback(t *testing.T) {
|
|
h := dirServer(t, map[string]string{"notes/plan.md": "content"})
|
|
|
|
shell := func(url string) {
|
|
t.Helper()
|
|
rec := get(t, h, url)
|
|
if rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
|
t.Fatalf("%s: want 200 html, got %d %s", url, rec.Code, rec.Header().Get("Content-Type"))
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `id="sidebar"`) {
|
|
t.Fatalf("%s: expected the app shell, got %.60q", url, rec.Body.String())
|
|
}
|
|
}
|
|
// Client routes all resolve to the shell, not a 404 or file content.
|
|
shell("/")
|
|
shell("/notes/plan.md") // a deep file route (not the raw file)
|
|
shell("/p-deadbeef/notes/plan.md") // hub-style route
|
|
shell("/join/abc123") // invite route
|
|
|
|
// Real assets are served as themselves.
|
|
if rec := get(t, h, "/app.js"); rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "javascript") {
|
|
t.Fatalf("/app.js: %d %s", rec.Code, rec.Header().Get("Content-Type"))
|
|
}
|
|
if rec := get(t, h, "/style.css"); rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "css") {
|
|
t.Fatalf("/style.css: %d %s", rec.Code, rec.Header().Get("Content-Type"))
|
|
}
|
|
|
|
// A mistyped API path is a genuine 404, not the shell.
|
|
if rec := get(t, h, "/api/bogus"); rec.Code != 404 {
|
|
t.Fatalf("/api/bogus: want 404, got %d", rec.Code)
|
|
}
|
|
}
|