feat: better permalinks for spaces

This commit is contained in:
juancwu 2026-04-11 22:00:02 +00:00
commit 7f9b38c33b
10 changed files with 113 additions and 22 deletions

View file

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

View file

@ -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))
}

View file

@ -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))
}
}
}

View file

@ -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(), "-")
}

View file

@ -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)
}
}
}

View file

@ -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")

View file

@ -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"))
})
}

View file

@ -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)
}

View file

@ -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) {
<a href={ routeurl.URL("page.app.spaces.space.overview", "spaceID", info.ID) } class="px-2 py-2 block rounded-md hover:bg-sidebar-accent">
<a href={ routeurl.URL("page.app.spaces.space.overview", "spaceSlug", info.Slug) } class="px-2 py-2 block rounded-md hover:bg-sidebar-accent">
<div class="flex items-center justify-between">
<div class="flex gap-4 items-center">
<div class="w-10 h-10 shrink-0 overflow-hidden rounded-md bg-muted flex items-center justify-center">

View file

@ -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) {
<div>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-4"}) {
<p class="text-xl text-success">Space successfully created!</p>
}
@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
}
}