diff --git a/internal/ctxkeys/ctx.go b/internal/ctxkeys/ctx.go index ed634b9..3617c31 100644 --- a/internal/ctxkeys/ctx.go +++ b/internal/ctxkeys/ctx.go @@ -8,7 +8,8 @@ import ( ) const ( - UserKey string = "user" + UserKey string = "user" + SpaceKey string = "space" URLPathKey string = "url_path" ConfigKey string = "config" @@ -25,6 +26,15 @@ func WithUser(ctx context.Context, user *model.User) context.Context { return context.WithValue(ctx, UserKey, user) } +func Space(ctx context.Context) *model.Space { + sp, _ := ctx.Value(SpaceKey).(*model.Space) + return sp +} + +func WithSpace(ctx context.Context, sp *model.Space) context.Context { + return context.WithValue(ctx, SpaceKey, sp) +} + func URLPath(ctx context.Context) string { path, _ := ctx.Value(URLPathKey).(string) diff --git a/internal/handler/space.go b/internal/handler/space.go index 865a691..31b64b6 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -6,6 +6,7 @@ import ( "strings" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "git.juancwu.dev/juancwu/budgit/internal/misc/slug" "git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/ui" "git.juancwu.dev/juancwu/budgit/internal/ui/blocks" @@ -61,6 +62,7 @@ func (h *spaceHandler) SpacesPage(w http.ResponseWriter, r *http.Request) { cards = append(cards, blocks.SpaceCardInfo{ ID: sp.ID, Name: sp.Name, + Slug: slug.Make(sp.Name), MemberCount: memberCount, TotalBalance: totalBalance, }) @@ -102,18 +104,14 @@ func (h *spaceHandler) HandleCreateSpace(w http.ResponseWriter, r *http.Request) return } - ui.Render(w, r, forms.CreateSpaceSuccess(sp.ID)) + ui.Render(w, r, forms.CreateSpaceSuccess(slug.Make(sp.Name))) } func (h *spaceHandler) SpaceOverviewPage(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - - space, err := h.spaceService.GetSpace(spaceID) - if err != nil { - slog.Error("failed to fetch space data", "error", err, "spaceID", spaceID) + space := ctxkeys.Space(r.Context()) + if space == nil { ui.Render(w, r, pages.NotFound()) return } - ui.Render(w, r, pages.SpaceOverview(space.Name)) } diff --git a/internal/middleware/space.go b/internal/middleware/space.go index fb47d95..9107e41 100644 --- a/internal/middleware/space.go +++ b/internal/middleware/space.go @@ -5,11 +5,16 @@ import ( "net/http" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "git.juancwu.dev/juancwu/budgit/internal/misc/slug" + "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/service" ) -// RequireSpaceAccess validates that a user is a member of the space they are trying to access. -// It expects a URL parameter named "spaceID". +// RequireSpaceAccess resolves the {spaceName} path parameter against the +// current user's spaces. If the user is not a member of a space with that +// name, the request is redirected to /forbidden. On success the matched +// space is stashed on the request context via ctxkeys.WithSpace so +// downstream handlers can read it without another DB lookup. func RequireSpaceAccess(spaceService *service.SpaceService) func(http.Handler) http.HandlerFunc { return func(next http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -20,26 +25,43 @@ func RequireSpaceAccess(spaceService *service.SpaceService) func(http.Handler) h return } - spaceID := r.PathValue("spaceID") - if spaceID == "" { - slog.Warn("RequireSpaceAccess middleware used on a route without a {spaceID} path parameter") + spaceSlug := r.PathValue("spaceSlug") + if spaceSlug == "" { + slog.Warn("RequireSpaceAccess middleware used on a route without a {spaceSlug} path parameter") notfound(w, r) return } - isMember, err := spaceService.IsMember(user.ID, spaceID) + spaces, err := spaceService.GetSpacesForUser(user.ID) if err != nil { - slog.Error("failed to check space membership", "error", err, "user_id", user.ID, "space_id", spaceID) + slog.Error("failed to load user spaces", "error", err, "user_id", user.ID) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - if !isMember { + var matched *model.Space + for _, sp := range spaces { + if slug.Make(sp.Name) != spaceSlug { + continue + } + if matched != nil { + slog.Warn("ambiguous space slug for user; using first match", + "user_id", user.ID, + "space_slug", spaceSlug, + "first", matched.ID, + "second", sp.ID, + ) + break + } + matched = sp + } + if matched == nil { redirect(w, r, "/forbidden", http.StatusSeeOther) return } - next.ServeHTTP(w, r) + ctx := ctxkeys.WithSpace(r.Context(), matched) + next.ServeHTTP(w, r.WithContext(ctx)) } } } diff --git a/internal/misc/slug/slug.go b/internal/misc/slug/slug.go new file mode 100644 index 0000000..4a455e1 --- /dev/null +++ b/internal/misc/slug/slug.go @@ -0,0 +1,37 @@ +// Package slug produces URL-friendly slugs from arbitrary strings. +package slug + +import ( + "strings" + "unicode" +) + +// Make converts s into a lowercase, hyphen-separated slug. Runs of +// non-alphanumeric characters collapse to a single "-", and leading or +// trailing hyphens are trimmed. Non-ASCII letters/digits are preserved +// (lowercased); everything else becomes a separator. +// +// Make("John's Space") // "johns-space" +// Make("Savings & Debt") // "savings-debt" +// Make(" --Hello-- ") // "hello" +func Make(s string) string { + var b strings.Builder + b.Grow(len(s)) + prevHyphen := true // suppresses leading hyphens + for _, r := range s { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + b.WriteRune(unicode.ToLower(r)) + prevHyphen = false + case r == '\'' || r == '`' || r == '"' || r == '’': + // Intra-word quote marks are stripped, not turned into separators, + // so "John's" → "johns" rather than "john-s". + default: + if !prevHyphen { + b.WriteByte('-') + prevHyphen = true + } + } + } + return strings.TrimRight(b.String(), "-") +} diff --git a/internal/misc/slug/slug_test.go b/internal/misc/slug/slug_test.go new file mode 100644 index 0000000..00064ad --- /dev/null +++ b/internal/misc/slug/slug_test.go @@ -0,0 +1,21 @@ +package slug + +import "testing" + +func TestMake(t *testing.T) { + cases := map[string]string{ + "John's Space": "johns-space", + "Savings & Debt": "savings-debt", + " --Hello-- ": "hello", + "Ada Lovelace": "ada-lovelace", + "Already-slug": "already-slug", + "": "", + "!!!": "", + "Foo Bar": "foo-bar", + } + for in, want := range cases { + if got := Make(in); got != want { + t.Errorf("Make(%q) = %q; want %q", in, got, want) + } + } +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index ba4823d..dc1af19 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -87,7 +87,7 @@ func SetupRoutes(a *app.App) http.Handler { g.Get("", spaceH.SpacesPage).Name("page.app.spaces") g.Get("/create", spaceH.CreateSpacePage).Name("page.app.spaces.create") g.Post("/create", spaceH.HandleCreateSpace).Name("action.app.spaces.create") - g.SubGroup("/{spaceID}", func(g *router.Group) { + g.SubGroup("/{spaceSlug}", func(g *router.Group) { spaceAccessMw := middleware.RequireSpaceAccess(a.SpaceService) g.Use(spaceAccessMw) g.Get("/overview", spaceH.SpaceOverviewPage).Name("page.app.spaces.space.overview") diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index 740ddf4..92c10f1 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -229,6 +229,7 @@ func TestURL_ResolvesNamedRoute(t *testing.T) { assert.Equal(t, "/privacy", routeurl.URL("page.public.privacy")) assert.Equal(t, "/app/spaces", routeurl.URL("page.app.spaces")) assert.Equal(t, "/join/abc123", routeurl.URL("page.public.join-space", "token", "abc123")) + assert.Equal(t, "/app/spaces/johns-space/overview", routeurl.URL("page.app.spaces.space.overview", "spaceSlug", "johns-space")) assert.Equal(t, "#", routeurl.URL("does.not.exist")) }) } diff --git a/internal/routeurl/routeurl.go b/internal/routeurl/routeurl.go index 9dda83a..f674f2e 100644 --- a/internal/routeurl/routeurl.go +++ b/internal/routeurl/routeurl.go @@ -5,6 +5,7 @@ package routeurl import ( + "net/url" "strings" "sync" ) @@ -46,7 +47,7 @@ func URL(name string, kv ...string) string { return "#" } for i := 0; i+1 < len(kv); i += 2 { - key, val := kv[i], kv[i+1] + key, val := kv[i], url.PathEscape(kv[i+1]) path = strings.Replace(path, "{"+key+"...}", val, 1) path = strings.Replace(path, "{"+key+"}", val, 1) } diff --git a/internal/ui/blocks/space_card.templ b/internal/ui/blocks/space_card.templ index e98ae98..9cc64bc 100644 --- a/internal/ui/blocks/space_card.templ +++ b/internal/ui/blocks/space_card.templ @@ -9,12 +9,13 @@ import "git.juancwu.dev/juancwu/budgit/internal/routeurl" type SpaceCardInfo struct { ID string Name string + Slug string MemberCount int TotalBalance decimal.Decimal } templ SpaceCard(info SpaceCardInfo) { - +
diff --git a/internal/ui/forms/create_space_success.templ b/internal/ui/forms/create_space_success.templ index 5e2487c..f174d5b 100644 --- a/internal/ui/forms/create_space_success.templ +++ b/internal/ui/forms/create_space_success.templ @@ -4,14 +4,14 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" import "git.juancwu.dev/juancwu/budgit/internal/routeurl" -templ CreateSpaceSuccess(spaceID string) { +templ CreateSpaceSuccess(spaceSlug string) {
@card.Card(card.Props{Class: "rounded-sm"}) { @card.Content(card.ContentProps{Class: "p-4"}) {

Space successfully created!

} @card.Footer() { - @button.Button(button.Props{Class: "w-full", Href: routeurl.URL("page.app.spaces.space.overview", "spaceID", spaceID)}) { + @button.Button(button.Props{Class: "w-full", Href: routeurl.URL("page.app.spaces.space.overview", "spaceSlug", spaceSlug)}) { Start tracking expenses } }