From 4dba55c807b8d22412b1e6544531ce2a9cd1c6ac Mon Sep 17 00:00:00 2001 From: arnef Date: Wed, 19 Aug 2026 07:29:32 +0200 Subject: [PATCH] Move web UI from /ui/ to /web/ path prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web UI is now mounted at /web/ (previously /ui/) — cmd/server/main.go wraps web.Server.Handler with http.StripPrefix("/web", ...), so internal/web's own routes stay unprefixed (/, /login, /logout, /shares/..., /static/...) and only the outer mux adds the prefix. All templates, redirects, and cookie paths updated accordingly. The root '/' route reverts to the original unauthenticated welcome page (linking to /web/), and /cal/, /card/, /files/ are unaffected. Also gitignore the bin/ build output directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 15 +++++++++------ .gitignore | 1 + README.md | 10 +++++----- cmd/server/main.go | 7 ++++--- internal/web/server.go | 21 +++++++++++---------- internal/web/server_test.go | 20 ++++++++++---------- internal/web/session.go | 4 ++-- internal/web/shares.go | 2 +- internal/web/templates/dashboard.templ | 4 ++-- internal/web/templates/dashboard_templ.go | 4 ++-- internal/web/templates/layout.templ | 8 ++++---- internal/web/templates/layout_templ.go | 4 ++-- internal/web/templates/login.templ | 4 ++-- internal/web/templates/login_templ.go | 4 ++-- web/static/app.css | 2 +- 15 files changed, 58 insertions(+), 52 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 93ac9f8..de38149 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,7 +4,7 @@ A self-hosted CalDAV, CardDAV, and WebDAV server written in Go, backed by a filesystem store, with calendar/address book sharing grants tracked in a small SQLite database. HTTP Basic Auth (bcrypt) with per-user isolated collections. A small server-rendered web UI (templ + Tailwind + htmx) at -`/ui/` lets users log in and manage their shares. +`/web/` lets users log in and manage their shares. ## Build, test, lint @@ -120,10 +120,13 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`, the caldav/carddav backends query the shares tables on every request (no caching), changes take effect immediately without restarting the server. -- `internal/web` — the web UI, mounted at `/ui/` in `cmd/server/main.go` - (`web.NewServer(cfg, st, dbase, logger).Handler(webstatic.FS())`), - entirely separate from `internal/auth`'s Basic Auth: logins go through - `/ui/login` (username/password checked against `cfg.Users` the same way +- `internal/web` — the web UI, mounted at `/web/` in `cmd/server/main.go` + (`mux.Handle("/web/", http.StripPrefix("/web", web.NewServer(cfg, st, + dbase, logger).Handler(webstatic.FS())))`, so `Server.Handler`'s own + routes are all unprefixed — `/login`, `/`, `/shares/...` — and only the + outer mux adds the `/web` prefix), entirely separate from `internal/auth`'s + Basic Auth: logins go through + `/web/login` (username/password checked against `cfg.Users` the same way Basic Auth does, via bcrypt) and issue an opaque random session token stored in the `web_sessions` SQLite table (`db.CreateSession`/ `SessionUser`/`DeleteSession`, see `internal/db/sessions.go`), set as an @@ -135,7 +138,7 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`, (`SharesOfCalendar`/`SharesOfAddressBook`) and what's shared with them (`CalendarsSharedWith`/`AddressBooksSharedWith`). `internal/web/shares.go` handles POST (create/update share) and DELETE (revoke) at - `/ui/shares/{calendar,addressbook}`, re-rendering just the affected + `/web/shares/{calendar,addressbook}`, re-rendering just the affected resource card for htmx's `hx-swap="outerHTML"`; it always checks `ownsResource` first so a user can only share resources actually configured for their own account (never someone else's, even via a diff --git a/.gitignore b/.gitignore index c9a3462..d3a5bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ data/ config.yaml web/node_modules/ +/bin/ diff --git a/README.md b/README.md index ebca317..0315381 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A self-hosted **CalDAV**, **CardDAV**, and **WebDAV** server written in Go. - Per-user isolated collections - **Calendar/address book sharing** — grant other users read or write access to your calendars/address books -- **Web UI** — a small dashboard (login, manage shares) at `/ui/`, built +- **Web UI** — a small dashboard (login, manage shares) at `/web/`, built with templ + Tailwind + htmx - Auto-discovery via `/.well-known/caldav` and `/.well-known/carddav` - Optional **TLS** (or use a reverse proxy) @@ -177,17 +177,17 @@ Read-only shares reject any write (PUT/DELETE) with `403 Forbidden`. ## Web UI -A small server-rendered dashboard is served at `/ui/` (separate from the +A small server-rendered dashboard is served at `/web/` (separate from the DAV endpoints, which stay on HTTP Basic Auth): -- **Login** (`/ui/login`) — cookie-based session, stored server-side in +- **Login** (`/web/login`) — cookie-based session, stored server-side in `nidus.db` (`web_sessions` table), independent of DAV Basic Auth. -- **Dashboard** (`/ui/`) — lists your own calendars/address books, who +- **Dashboard** (`/web/`) — lists your own calendars/address books, who they're shared with, and any resources other users have shared with you. - **Share management** — add/remove shares directly from the dashboard (same effect as `nidusctl`); updates happen in place via [htmx](https://htmx.org/) without a full page reload. -- **Logout** (`/ui/logout`). +- **Logout** (`/web/logout`). Implementation: [templ](https://templ.guide/) for type-safe Go HTML templates, [Tailwind CSS v4](https://tailwindcss.com/) for styling, and diff --git a/cmd/server/main.go b/cmd/server/main.go index 240c663..f2cdcfd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -137,8 +137,9 @@ func buildMux( mux := http.NewServeMux() // Web UI (own cookie-based auth, not Basic Auth) — dashboard, login, - // share management. - mux.Handle("/ui/", webUI.Handler(webstatic.FS())) + // share management. Mounted at "/web/"; /cal/, /card/, /files/ keep + // their own dedicated prefixes. + mux.Handle("/web/", http.StripPrefix("/web", webUI.Handler(webstatic.FS()))) // /.well-known/ redirects for auto-discovery mux.HandleFunc("/.well-known/caldav", func(w http.ResponseWriter, r *http.Request) { @@ -256,6 +257,6 @@ const welcomePage = `
  • %s/.well-known/carddav
  • Authentication: HTTP Basic Auth

    -

    Open the web dashboard →

    +

    Open the web dashboard →

    ` diff --git a/internal/web/server.go b/internal/web/server.go index fafc67f..35f0aa8 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -29,19 +29,20 @@ func NewServer(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.L return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger} } -// Handler returns the http.Handler serving the web UI, mounted at "/ui/" +// Handler returns the http.Handler serving the web UI, mounted at "/web/" // by the caller (cmd/server). staticFS serves the compiled Tailwind CSS -// and any other static assets. +// and any other static assets. Since it's mounted with a path prefix, +// the caller must wrap this handler in http.StripPrefix("/web", ...). func (s *Server) Handler(staticFS http.FileSystem) http.Handler { mux := http.NewServeMux() - mux.Handle("/ui/static/", http.StripPrefix("/ui/static/", http.FileServer(staticFS))) + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(staticFS))) - mux.HandleFunc("/ui/login", s.handleLogin) - mux.HandleFunc("/ui/logout", s.handleLogout) - mux.HandleFunc("/ui/", s.requireLogin(s.handleDashboard)) - mux.HandleFunc("/ui/shares/calendar", s.requireLogin(s.handleCalendarShare)) - mux.HandleFunc("/ui/shares/addressbook", s.requireLogin(s.handleAddressBookShare)) + mux.HandleFunc("/login", s.handleLogin) + mux.HandleFunc("/logout", s.handleLogout) + mux.HandleFunc("/", s.requireLogin(s.handleDashboard)) + mux.HandleFunc("/shares/calendar", s.requireLogin(s.handleCalendarShare)) + mux.HandleFunc("/shares/addressbook", s.requireLogin(s.handleAddressBookShare)) return mux } @@ -88,7 +89,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } s.setSessionCookie(w, token) - http.Redirect(w, r, "/ui/", http.StatusSeeOther) + http.Redirect(w, r, "/web/", http.StatusSeeOther) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { @@ -96,5 +97,5 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { _ = s.dbase.DeleteSession(cookie.Value) } s.clearSessionCookie(w) - http.Redirect(w, r, "/ui/login", http.StatusSeeOther) + http.Redirect(w, r, "/web/login", http.StatusSeeOther) } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 372c5d4..4661d69 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -51,7 +51,7 @@ func newTestServer(t *testing.T) *Server { func loginAs(t *testing.T, handler http.Handler, username, password string) *http.Cookie { t.Helper() form := url.Values{"username": {username}, "password": {password}} - req := httptest.NewRequest(http.MethodPost, "/ui/login", strings.NewReader(form.Encode())) + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) @@ -80,7 +80,7 @@ func TestLoginSuccessAndFailure(t *testing.T) { // Wrong password. form := url.Values{"username": {"alice"}, "password": {"wrong"}} - req := httptest.NewRequest(http.MethodPost, "/ui/login", strings.NewReader(form.Encode())) + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) @@ -96,15 +96,15 @@ func TestDashboardRequiresLogin(t *testing.T) { s := newTestServer(t) handler := s.Handler(emptyStaticFS{}) - req := httptest.NewRequest(http.MethodGet, "/ui/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Fatalf("expected redirect to login, got %d", rr.Code) } - if loc := rr.Result().Header.Get("Location"); loc != "/ui/login" { - t.Fatalf("expected redirect to /ui/login, got %q", loc) + if loc := rr.Result().Header.Get("Location"); loc != "/web/login" { + t.Fatalf("expected redirect to /web/login, got %q", loc) } } @@ -113,7 +113,7 @@ func TestDashboardShowsOwnResources(t *testing.T) { handler := s.Handler(emptyStaticFS{}) cookie := loginAs(t, handler, "alice", "password") - req := httptest.NewRequest(http.MethodGet, "/ui/", nil) + req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) @@ -134,7 +134,7 @@ func TestShareUnshareCalendarFlow(t *testing.T) { // Share alice's "work" calendar with bob, write access. form := url.Values{"resource": {"work"}, "shared_with": {"bob"}, "permission": {"write"}} - req := httptest.NewRequest(http.MethodPost, "/ui/shares/calendar", strings.NewReader(form.Encode())) + req := httptest.NewRequest(http.MethodPost, "/shares/calendar", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rr := httptest.NewRecorder() @@ -155,7 +155,7 @@ func TestShareUnshareCalendarFlow(t *testing.T) { } // Unshare — htmx v2 sends DELETE params as a URL query string. - req = httptest.NewRequest(http.MethodDelete, "/ui/shares/calendar?resource=work&shared_with=bob", nil) + req = httptest.NewRequest(http.MethodDelete, "/shares/calendar?resource=work&shared_with=bob", nil) req.AddCookie(cookie) rr = httptest.NewRecorder() handler.ServeHTTP(rr, req) @@ -182,7 +182,7 @@ func TestCannotShareResourceNotOwned(t *testing.T) { // alice doesn't own "personal" (that's bob's calendar). form := url.Values{"resource": {"personal"}, "shared_with": {"bob"}, "permission": {"write"}} - req := httptest.NewRequest(http.MethodPost, "/ui/shares/calendar", strings.NewReader(form.Encode())) + req := httptest.NewRequest(http.MethodPost, "/shares/calendar", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(cookie) rr := httptest.NewRecorder() @@ -198,7 +198,7 @@ func TestLogoutClearsSession(t *testing.T) { handler := s.Handler(emptyStaticFS{}) cookie := loginAs(t, handler, "alice", "password") - req := httptest.NewRequest(http.MethodGet, "/ui/logout", nil) + req := httptest.NewRequest(http.MethodGet, "/logout", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) diff --git a/internal/web/session.go b/internal/web/session.go index 87adcac..f0c25b2 100644 --- a/internal/web/session.go +++ b/internal/web/session.go @@ -26,13 +26,13 @@ func (s *Server) requireLogin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(sessionCookieName) if err != nil { - http.Redirect(w, r, "/ui/login", http.StatusSeeOther) + http.Redirect(w, r, "/web/login", http.StatusSeeOther) return } username, err := s.dbase.SessionUser(cookie.Value) if err != nil { s.clearSessionCookie(w) - http.Redirect(w, r, "/ui/login", http.StatusSeeOther) + http.Redirect(w, r, "/web/login", http.StatusSeeOther) return } ctx := context.WithValue(r.Context(), userCtxKey, username) diff --git a/internal/web/shares.go b/internal/web/shares.go index 999461f..5e570f5 100644 --- a/internal/web/shares.go +++ b/internal/web/shares.go @@ -10,7 +10,7 @@ import ( // handleCalendarShare handles POST (create/update share) and DELETE // (revoke share) for the current user's calendars, mounted at -// /ui/shares/calendar. htmx sends the resource + shared_with (+ permission +// /shares/calendar. htmx sends the resource + shared_with (+ permission // for POST) as form values and expects the updated resource card HTML // back for an out-of-band swap. func (s *Server) handleCalendarShare(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/templates/dashboard.templ b/internal/web/templates/dashboard.templ index 1a6d7d4..7ffb9b5 100644 --- a/internal/web/templates/dashboard.templ +++ b/internal/web/templates/dashboard.templ @@ -113,9 +113,9 @@ templ ResourceCardView(r ResourceCard) { func shareEndpoint(kind string) string { if kind == "calendar" { - return "/ui/shares/calendar" + return "/web/shares/calendar" } - return "/ui/shares/addressbook" + return "/web/shares/addressbook" } func shareVals(resource, sharedWith string) string { diff --git a/internal/web/templates/dashboard_templ.go b/internal/web/templates/dashboard_templ.go index d443395..247d802 100644 --- a/internal/web/templates/dashboard_templ.go +++ b/internal/web/templates/dashboard_templ.go @@ -361,9 +361,9 @@ func ResourceCardView(r ResourceCard) templ.Component { func shareEndpoint(kind string) string { if kind == "calendar" { - return "/ui/shares/calendar" + return "/web/shares/calendar" } - return "/ui/shares/addressbook" + return "/web/shares/addressbook" } func shareVals(resource, sharedWith string) string { diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ index bfdeccd..be152cb 100644 --- a/internal/web/templates/layout.templ +++ b/internal/web/templates/layout.templ @@ -7,17 +7,17 @@ templ Layout(title string, username string) { { title } · nidus - - + +