diff --git a/internal/app/app.go b/internal/app/app.go index 1f1ca65..4034b81 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,6 +17,7 @@ type App struct { AuthService *service.AuthService EmailService *service.EmailService ProfileService *service.ProfileService + SpaceService *service.SpaceService } func New(cfg *config.Config) (*App, error) { @@ -35,8 +36,10 @@ func New(cfg *config.Config) (*App, error) { userRepository := repository.NewUserRepository(database) profileRepository := repository.NewProfileRepository(database) tokenRepository := repository.NewTokenRepository(database) + spaceRepository := repository.NewSpaceRepository(database) userService := service.NewUserService(userRepository) + spaceService := service.NewSpaceService(spaceRepository) emailService := service.NewEmailService( emailClient, cfg.MailerEmailFrom, @@ -49,6 +52,7 @@ func New(cfg *config.Config) (*App, error) { userRepository, profileRepository, tokenRepository, + spaceService, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, @@ -63,6 +67,7 @@ func New(cfg *config.Config) (*App, error) { AuthService: authService, EmailService: emailService, ProfileService: profileService, + SpaceService: spaceService, }, nil } diff --git a/internal/db/migrations/00005_create_spaces_tables.sql b/internal/db/migrations/00005_create_spaces_tables.sql new file mode 100644 index 0000000..8334601 --- /dev/null +++ b/internal/db/migrations/00005_create_spaces_tables.sql @@ -0,0 +1,32 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS spaces ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + owner_id TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS space_members ( + space_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner', 'member')), + joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (space_id, user_id), + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_id); +CREATE INDEX IF NOT EXISTS idx_space_members_user ON space_members(user_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_space_members_user; +DROP INDEX IF EXISTS idx_spaces_owner; +DROP TABLE IF EXISTS space_members; +DROP TABLE IF EXISTS spaces; +-- +goose StatementEnd diff --git a/internal/handler/home.go b/internal/handler/home.go index 41b8f0c..4d150c2 100644 --- a/internal/handler/home.go +++ b/internal/handler/home.go @@ -27,3 +27,7 @@ func (h *homeHandler) HomePage(w http.ResponseWriter, r *http.Request) { func (home *homeHandler) NotFoundPage(w http.ResponseWriter, r *http.Request) { ui.Render(w, r, pages.NotFound()) } + +func (h *homeHandler) ForbiddenPage(w http.ResponseWriter, r *http.Request) { + ui.Render(w, r, pages.Forbidden()) +} diff --git a/internal/middleware/space.go b/internal/middleware/space.go new file mode 100644 index 0000000..87d06bc --- /dev/null +++ b/internal/middleware/space.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "log/slog" + "net/http" + + "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "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". +func RequireSpaceAccess(spaceService *service.SpaceService) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := ctxkeys.User(r.Context()) + if user == nil { + // This should be caught by RequireAuth first, but as a safeguard. + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + spaceID := r.PathValue("spaceID") + if spaceID == "" { + slog.Warn("RequireSpaceAccess middleware used on a route without a {spaceID} path parameter") + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + isMember, err := spaceService.IsMember(user.ID, spaceID) + if err != nil { + slog.Error("failed to check space membership", "error", err, "user_id", user.ID, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if !isMember { + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", "/forbidden") + w.WriteHeader(http.StatusSeeOther) + return + } + http.Redirect(w, r, "/forbidden", http.StatusSeeOther) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/model/space.go b/internal/model/space.go new file mode 100644 index 0000000..93e8c9e --- /dev/null +++ b/internal/model/space.go @@ -0,0 +1,25 @@ +package model + +import "time" + +type Role string + +const ( + RoleOwner Role = "owner" + RoleMember Role = "member" +) + +type Space struct { + ID string `db:"id"` + Name string `db:"name"` + OwnerID string `db:"owner_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type SpaceMember struct { + SpaceID string `db:"space_id"` + UserID string `db:"user_id"` + Role Role `db:"role"` + JoinedAt time.Time `db:"joined_at"` +} diff --git a/internal/repository/space.go b/internal/repository/space.go new file mode 100644 index 0000000..0f7db6e --- /dev/null +++ b/internal/repository/space.go @@ -0,0 +1,108 @@ +package repository + +import ( + "database/sql" + "errors" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "github.com/jmoiron/sqlx" +) + +var ( + ErrSpaceNotFound = errors.New("space not found") +) + +type SpaceRepository interface { + Create(space *model.Space) error + ByID(id string) (*model.Space, error) + ByUserID(userID string) ([]*model.Space, error) + AddMember(spaceID, userID string, role model.Role) error + RemoveMember(spaceID, userID string) error + IsMember(spaceID, userID string) (bool, error) +} + +type spaceRepository struct { + db *sqlx.DB +} + +func NewSpaceRepository(db *sqlx.DB) SpaceRepository { + return &spaceRepository{db: db} +} + +func (r *spaceRepository) Create(space *model.Space) error { + tx, err := r.db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + + // Insert Space + querySpace := `INSERT INTO spaces (id, name, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5);` + _, err = tx.Exec(querySpace, space.ID, space.Name, space.OwnerID, space.CreatedAt, space.UpdatedAt) + if err != nil { + return err + } + + // Insert Owner as Member + queryMember := `INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES ($1, $2, $3, $4);` + _, err = tx.Exec(queryMember, space.ID, space.OwnerID, model.RoleOwner, space.CreatedAt) + if err != nil { + return err + } + + return tx.Commit() +} + +func (r *spaceRepository) ByID(id string) (*model.Space, error) { + space := &model.Space{} + query := `SELECT * FROM spaces WHERE id = $1;` + + err := r.db.Get(space, query, id) + if err == sql.ErrNoRows { + return nil, ErrSpaceNotFound + } + + return space, err +} + +func (r *spaceRepository) ByUserID(userID string) ([]*model.Space, error) { + var spaces []*model.Space + // Select spaces where user is a member + query := ` + SELECT s.* + FROM spaces s + JOIN space_members sm ON s.id = sm.space_id + WHERE sm.user_id = $1 + ORDER BY s.created_at DESC; + ` + + err := r.db.Select(&spaces, query, userID) + if err != nil { + return nil, err + } + + return spaces, nil +} + +func (r *spaceRepository) AddMember(spaceID, userID string, role model.Role) error { + query := `INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES ($1, $2, $3, $4);` + _, err := r.db.Exec(query, spaceID, userID, role, time.Now()) + return err +} + +func (r *spaceRepository) RemoveMember(spaceID, userID string) error { + query := `DELETE FROM space_members WHERE space_id = $1 AND user_id = $2;` + _, err := r.db.Exec(query, spaceID, userID) + return err +} + +func (r *spaceRepository) IsMember(spaceID, userID string) (bool, error) { + var count int + query := `SELECT count(*) FROM space_members WHERE space_id = $1 AND user_id = $2;` + err := r.db.Get(&count, query, spaceID, userID) + if err != nil { + return false, err + } + return count > 0, nil +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 962d9ca..e85c643 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -27,6 +27,7 @@ func SetupRoutes(a *app.App) http.Handler { // Home mux.HandleFunc("GET /{$}", home.HomePage) + mux.HandleFunc("GET /forbidden", home.ForbiddenPage) // Auth pages authRateLimiter := middleware.RateLimitAuth() diff --git a/internal/service/auth.go b/internal/service/auth.go index 9bea30d..2d21190 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -36,6 +36,7 @@ type AuthService struct { userRepository repository.UserRepository profileRepository repository.ProfileRepository tokenRepository repository.TokenRepository + spaceService *SpaceService jwtSecret string jwtExpiry time.Duration tokenMagicLinkExpiry time.Duration @@ -47,6 +48,7 @@ func NewAuthService( userRepository repository.UserRepository, profileRepository repository.ProfileRepository, tokenRepository repository.TokenRepository, + spaceService *SpaceService, jwtSecret string, jwtExpiry time.Duration, tokenMagicLinkExpiry time.Duration, @@ -57,6 +59,7 @@ func NewAuthService( userRepository: userRepository, profileRepository: profileRepository, tokenRepository: tokenRepository, + spaceService: spaceService, jwtSecret: jwtSecret, jwtExpiry: jwtExpiry, tokenMagicLinkExpiry: tokenMagicLinkExpiry, @@ -214,6 +217,12 @@ func (s *AuthService) SendMagicLink(email string) error { return fmt.Errorf("failed to create profile: %w", err) } + _, err = s.spaceService.EnsurePersonalSpace(user) + if err != nil { + // Log the error but don't fail the whole auth flow + slog.Error("failed to create personal space for new user", "error", err, "user_id", user.ID) + } + slog.Info("new passwordless user created", "email", email, "user_id", user.ID) } else { // user look up unexpected error diff --git a/internal/service/space.go b/internal/service/space.go new file mode 100644 index 0000000..d0fd8c4 --- /dev/null +++ b/internal/service/space.go @@ -0,0 +1,81 @@ +package service + +import ( + "fmt" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/repository" + "github.com/google/uuid" +) + +const PersonalSpaceName = "Personal Space" + +type SpaceService struct { + spaceRepo repository.SpaceRepository +} + +func NewSpaceService(spaceRepo repository.SpaceRepository) *SpaceService { + return &SpaceService{ + spaceRepo: spaceRepo, + } +} + +// CreateSpace creates a new space and sets the owner. +func (s *SpaceService) CreateSpace(name string, ownerID string) (*model.Space, error) { + if name == "" { + return nil, fmt.Errorf("space name cannot be empty") + } + + space := &model.Space{ + ID: uuid.NewString(), + Name: name, + OwnerID: ownerID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err := s.spaceRepo.Create(space) + if err != nil { + return nil, fmt.Errorf("failed to create space: %w", err) + } + + return space, nil +} + +// EnsurePersonalSpace creates a "Personal Space" for a user if one doesn't exist. +func (s *SpaceService) EnsurePersonalSpace(user *model.User) (*model.Space, error) { + spaces, err := s.spaceRepo.ByUserID(user.ID) + if err != nil { + return nil, fmt.Errorf("failed to get user spaces: %w", err) + } + + // Check if a personal space already exists. + // We identify it by the user being the owner and the name being the default. + for _, space := range spaces { + if space.OwnerID == user.ID && space.Name == PersonalSpaceName { + return space, nil // Personal space already exists + } + } + + // If no personal space, create one. + return s.CreateSpace(PersonalSpaceName, user.ID) +} + +// GetSpacesForUser returns all spaces a user is a member of. +func (s *SpaceService) GetSpacesForUser(userID string) ([]*model.Space, error) { + spaces, err := s.spaceRepo.ByUserID(userID) + if err != nil { + return nil, fmt.Errorf("failed to get spaces for user: %w", err) + } + return spaces, nil +} + +// IsMember checks if a user is a member of a given space. +func (s *SpaceService) IsMember(userID, spaceID string) (bool, error) { + isMember, err := s.spaceRepo.IsMember(spaceID, userID) + if err != nil { + return false, fmt.Errorf("failed to check membership: %w", err) + } + return isMember, nil +} diff --git a/internal/ui/pages/public_forbidden.templ b/internal/ui/pages/public_forbidden.templ new file mode 100644 index 0000000..56d95ae --- /dev/null +++ b/internal/ui/pages/public_forbidden.templ @@ -0,0 +1,18 @@ +package pages + +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + +templ Forbidden() { + @layouts.Base() { +
+
+

403

+

Access Denied

+

You do not have permission to access this page.

+ + ← Back to Dashboard + +
+
+ } +}