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) {
-