diff --git a/.env.example b/.env.example index ab9e1e3..84e1965 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,6 @@ DATABASE_URL=postgres://user:password@localhost:5432/forgejo_tickets?sslmode=dis # Server PUBLIC_ADDR=:8080 -ADMIN_ADDR=:8081 BASE_URL=http://localhost:8080 # Sessions (generate with: openssl rand -hex 32) @@ -32,5 +31,5 @@ APPLE_TEAM_ID= APPLE_KEY_ID= APPLE_KEY_PATH= -# Admin (comma-separated Tailscale login names) -TAILSCALE_ALLOWED_USERS=user@example.com +# Admin (email of user to promote to admin on startup) +INITIAL_ADMIN_EMAIL=admin@example.com diff --git a/Dockerfile b/Dockerfile index 4baa8d4..9455d8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,5 +25,5 @@ WORKDIR /app COPY --from=builder /app/forgejo-tickets . COPY --from=builder /app/web/templates web/templates COPY --from=builder /app/web/static web/static -EXPOSE 8080 8081 +EXPOSE 8080 CMD ["./forgejo-tickets"] diff --git a/cmd/server/main.go b/cmd/server/main.go index ae49668..3580363 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -15,7 +15,6 @@ import ( "github.com/mattnite/forgejo-tickets/internal/database" "github.com/mattnite/forgejo-tickets/internal/email" "github.com/mattnite/forgejo-tickets/internal/forgejo" - adminhandlers "github.com/mattnite/forgejo-tickets/internal/handlers/admin" publichandlers "github.com/mattnite/forgejo-tickets/internal/handlers/public" "github.com/mattnite/forgejo-tickets/internal/templates" "github.com/rs/zerolog" @@ -64,7 +63,7 @@ func main() { go sessionStore.Cleanup(ctx, 30*time.Minute) go authService.CleanupExpiredTokens(ctx, 1*time.Hour) - publicRouter := publichandlers.NewRouter(publichandlers.Dependencies{ + router := publichandlers.NewRouter(publichandlers.Dependencies{ DB: db, Renderer: renderer, Auth: authService, @@ -74,61 +73,33 @@ func main() { Config: cfg, }) - adminRouter := adminhandlers.NewRouter(adminhandlers.Dependencies{ - DB: db, - Renderer: renderer, - Auth: authService, - SessionStore: sessionStore, - EmailClient: emailClient, - ForgejoClient: forgejoClient, - Config: cfg, - }) - - publicServer := &http.Server{ + server := &http.Server{ Addr: cfg.PublicAddr, - Handler: publicRouter, - ReadTimeout: 15 * time.Second, - WriteTimeout: 15 * time.Second, - IdleTimeout: 60 * time.Second, - } - - adminServer := &http.Server{ - Addr: cfg.AdminAddr, - Handler: adminRouter, + Handler: router, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } go func() { - log.Info().Msgf("Public server listening on %s", cfg.PublicAddr) - if err := publicServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatal().Msgf("public server error: %v", err) - } - }() - - go func() { - log.Info().Msgf("Admin server listening on %s", cfg.AdminAddr) - if err := adminServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatal().Msgf("admin server error: %v", err) + log.Info().Msgf("Server listening on %s", cfg.PublicAddr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal().Msgf("server error: %v", err) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit - log.Info().Msg("Shutting down servers...") + log.Info().Msg("Shutting down server...") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) defer shutdownCancel() - if err := publicServer.Shutdown(shutdownCtx); err != nil { - log.Error().Err(err).Msg("public server shutdown error") - } - if err := adminServer.Shutdown(shutdownCtx); err != nil { - log.Error().Err(err).Msg("admin server shutdown error") + if err := server.Shutdown(shutdownCtx); err != nil { + log.Error().Err(err).Msg("server shutdown error") } cancel() - log.Info().Msg("Servers stopped") + log.Info().Msg("Server stopped") } diff --git a/internal/auth/session.go b/internal/auth/session.go index a42b93b..b73355b 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -55,6 +55,21 @@ func RequireAuth(c *gin.Context) { c.Next() } +func RequireAdmin(c *gin.Context) { + user := CurrentUser(c) + if user == nil { + c.Redirect(http.StatusSeeOther, "/login") + c.Abort() + return + } + if !user.IsAdmin() { + c.Redirect(http.StatusSeeOther, "/") + c.Abort() + return + } + c.Next() +} + func CurrentUser(c *gin.Context) *models.User { user, exists := c.Get(userContextKey) if !exists { diff --git a/internal/config/config.go b/internal/config/config.go index 5602a91..44fe3e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,7 +12,6 @@ type Config struct { // Server PublicAddr string - AdminAddr string BaseURL string // Sessions @@ -42,6 +41,7 @@ type Config struct { AppleKeyPath string // Admin + InitialAdminEmail string TailscaleAllowedUsers []string } @@ -49,7 +49,6 @@ func Load() (*Config, error) { cfg := &Config{ DatabaseURL: getEnv("DATABASE_URL", ""), PublicAddr: getEnv("PUBLIC_ADDR", ":8080"), - AdminAddr: getEnv("ADMIN_ADDR", ":8081"), BaseURL: getEnv("BASE_URL", "http://localhost:8080"), SessionSecret: getEnv("SESSION_SECRET", ""), ForgejoURL: getEnv("FORGEJO_URL", ""), @@ -67,6 +66,8 @@ func Load() (*Config, error) { AppleKeyPath: getEnv("APPLE_KEY_PATH", ""), } + cfg.InitialAdminEmail = getEnv("INITIAL_ADMIN_EMAIL", "") + if allowed := getEnv("TAILSCALE_ALLOWED_USERS", ""); allowed != "" { cfg.TailscaleAllowedUsers = strings.Split(allowed, ",") for i := range cfg.TailscaleAllowedUsers { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e22ff99..d0142e4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,13 +10,13 @@ import ( func clearConfigEnv(t *testing.T) { t.Helper() envVars := []string{ - "DATABASE_URL", "PUBLIC_ADDR", "ADMIN_ADDR", "BASE_URL", + "DATABASE_URL", "PUBLIC_ADDR", "BASE_URL", "SESSION_SECRET", "FORGEJO_URL", "FORGEJO_API_TOKEN", "POSTMARK_SERVER_TOKEN", "POSTMARK_FROM_EMAIL", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_TENANT_ID", "APPLE_CLIENT_ID", "APPLE_TEAM_ID", "APPLE_KEY_ID", "APPLE_KEY_PATH", - "TAILSCALE_ALLOWED_USERS", + "INITIAL_ADMIN_EMAIL", } for _, v := range envVars { os.Unsetenv(v) @@ -86,9 +86,6 @@ func TestLoad_DefaultValues(t *testing.T) { if cfg.PublicAddr != ":8080" { t.Errorf("expected default PublicAddr %q, got %q", ":8080", cfg.PublicAddr) } - if cfg.AdminAddr != ":8081" { - t.Errorf("expected default AdminAddr %q, got %q", ":8081", cfg.AdminAddr) - } if cfg.BaseURL != "http://localhost:8080" { t.Errorf("expected default BaseURL %q, got %q", "http://localhost:8080", cfg.BaseURL) } @@ -102,7 +99,6 @@ func TestLoad_OverrideDefaults(t *testing.T) { t.Setenv("DATABASE_URL", "postgres://localhost/test") t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch") t.Setenv("PUBLIC_ADDR", ":9090") - t.Setenv("ADMIN_ADDR", ":9091") t.Setenv("BASE_URL", "https://example.com") cfg, err := Load() @@ -113,14 +109,27 @@ func TestLoad_OverrideDefaults(t *testing.T) { if cfg.PublicAddr != ":9090" { t.Errorf("expected PublicAddr %q, got %q", ":9090", cfg.PublicAddr) } - if cfg.AdminAddr != ":9091" { - t.Errorf("expected AdminAddr %q, got %q", ":9091", cfg.AdminAddr) - } if cfg.BaseURL != "https://example.com" { t.Errorf("expected BaseURL %q, got %q", "https://example.com", cfg.BaseURL) } } +func TestLoad_InitialAdminEmail(t *testing.T) { + clearConfigEnv(t) + t.Setenv("DATABASE_URL", "postgres://localhost/test") + t.Setenv("SESSION_SECRET", "test-session-secret-that-is-32ch") + t.Setenv("INITIAL_ADMIN_EMAIL", "admin@example.com") + + cfg, err := Load() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if cfg.InitialAdminEmail != "admin@example.com" { + t.Errorf("expected InitialAdminEmail %q, got %q", "admin@example.com", cfg.InitialAdminEmail) + } +} + func TestLoad_TailscaleAllowedUsers(t *testing.T) { clearConfigEnv(t) t.Setenv("DATABASE_URL", "postgres://localhost/test") diff --git a/internal/handlers/admin/auth.go b/internal/handlers/admin/auth.go deleted file mode 100644 index 0e22011..0000000 --- a/internal/handlers/admin/auth.go +++ /dev/null @@ -1,71 +0,0 @@ -package admin - -import ( - "encoding/json" - "fmt" - "net" - "net/http" - - "github.com/gin-gonic/gin" - "github.com/rs/zerolog/log" -) - -type TailscaleAuth struct { - allowedUsers []string -} - -type tailscaleWhoisResponse struct { - UserProfile struct { - LoginName string `json:"LoginName"` - } `json:"UserProfile"` -} - -func (t *TailscaleAuth) Middleware(c *gin.Context) { - if len(t.allowedUsers) == 0 { - // No allowed users configured - allow all (dev mode) - c.Next() - return - } - - remoteAddr := c.Request.RemoteAddr - host, _, err := net.SplitHostPort(remoteAddr) - if err != nil { - host = remoteAddr - } - - whoisURL := fmt.Sprintf("http://100.100.100.100/localapi/v0/whois?addr=%s", host) - resp, err := http.Get(whoisURL) - if err != nil { - log.Error().Err(err).Msg("tailscale whois error") - c.String(http.StatusUnauthorized, "Unauthorized") - c.Abort() - return - } - defer resp.Body.Close() - - var whois tailscaleWhoisResponse - if err := json.NewDecoder(resp.Body).Decode(&whois); err != nil { - log.Error().Err(err).Msg("tailscale whois decode error") - c.String(http.StatusUnauthorized, "Unauthorized") - c.Abort() - return - } - - loginName := whois.UserProfile.LoginName - allowed := false - for _, u := range t.allowedUsers { - if u == loginName { - allowed = true - break - } - } - - if !allowed { - log.Error().Msgf("tailscale auth: user %q not in allowed list", loginName) - c.String(http.StatusForbidden, "Forbidden") - c.Abort() - return - } - - c.Next() -} diff --git a/internal/handlers/admin/repos.go b/internal/handlers/admin/repos.go index a3ba5a9..2d43d6f 100644 --- a/internal/handlers/admin/repos.go +++ b/internal/handlers/admin/repos.go @@ -102,7 +102,7 @@ func (h *RepoHandler) Create(c *gin.Context) { return } - c.Redirect(http.StatusSeeOther, "/repos/"+repo.ID.String()+"/edit") + c.Redirect(http.StatusSeeOther, "/admin/repos/"+repo.ID.String()+"/edit") } func (h *RepoHandler) EditForm(c *gin.Context) { @@ -163,5 +163,5 @@ func (h *RepoHandler) Update(c *gin.Context) { return } - c.Redirect(http.StatusSeeOther, "/repos/"+repoID.String()+"/edit") + c.Redirect(http.StatusSeeOther, "/admin/repos/"+repoID.String()+"/edit") } diff --git a/internal/handlers/admin/routes.go b/internal/handlers/admin/routes.go index d214d15..4b3eb26 100644 --- a/internal/handlers/admin/routes.go +++ b/internal/handlers/admin/routes.go @@ -1,14 +1,11 @@ package admin import ( - "strings" - "github.com/gin-gonic/gin" "github.com/mattnite/forgejo-tickets/internal/auth" "github.com/mattnite/forgejo-tickets/internal/config" "github.com/mattnite/forgejo-tickets/internal/email" "github.com/mattnite/forgejo-tickets/internal/forgejo" - "github.com/mattnite/forgejo-tickets/internal/middleware" "github.com/mattnite/forgejo-tickets/internal/templates" "gorm.io/gorm" ) @@ -23,49 +20,29 @@ type Dependencies struct { Config *config.Config } -func NewRouter(deps Dependencies) *gin.Engine { - r := gin.New() +func RegisterRoutes(g *gin.RouterGroup, deps Dependencies) { + dashboardHandler := &DashboardHandler{deps: deps} + g.GET("/", dashboardHandler.Index) - r.Use(middleware.RequestID) - r.Use(middleware.Logging) - r.Use(middleware.Recovery) - r.Use(middleware.SecurityHeaders(strings.HasPrefix(deps.Config.BaseURL, "https"))) + userHandler := &UserHandler{deps: deps} + g.GET("/users", userHandler.List) + g.GET("/users/pending", userHandler.PendingList) + g.GET("/users/new", userHandler.NewForm) + g.GET("/users/:id", userHandler.Detail) + g.POST("/users", userHandler.Create) + g.POST("/users/:id/approve", userHandler.Approve) + g.POST("/users/:id/reject", userHandler.Reject) + g.POST("/users/:id/repos", userHandler.UpdateRepos) - tsAuth := &TailscaleAuth{allowedUsers: deps.Config.TailscaleAllowedUsers} - r.Use(tsAuth.Middleware) + ticketHandler := &TicketHandler{deps: deps} + g.GET("/tickets", ticketHandler.List) + g.GET("/tickets/:id", ticketHandler.Detail) + g.POST("/tickets/:id/status", ticketHandler.UpdateStatus) - csrfSecret := []byte(deps.Config.SessionSecret) - isSecure := strings.HasPrefix(deps.Config.BaseURL, "https") - csrfMiddleware := middleware.CSRF(csrfSecret, isSecure) - - csrf := r.Group("/") - csrf.Use(csrfMiddleware) - { - dashboardHandler := &DashboardHandler{deps: deps} - csrf.GET("/", dashboardHandler.Index) - - userHandler := &UserHandler{deps: deps} - csrf.GET("/users", userHandler.List) - csrf.GET("/users/pending", userHandler.PendingList) - csrf.GET("/users/new", userHandler.NewForm) - csrf.GET("/users/:id", userHandler.Detail) - csrf.POST("/users", userHandler.Create) - csrf.POST("/users/:id/approve", userHandler.Approve) - csrf.POST("/users/:id/reject", userHandler.Reject) - csrf.POST("/users/:id/repos", userHandler.UpdateRepos) - - ticketHandler := &TicketHandler{deps: deps} - csrf.GET("/tickets", ticketHandler.List) - csrf.GET("/tickets/:id", ticketHandler.Detail) - csrf.POST("/tickets/:id/status", ticketHandler.UpdateStatus) - - repoHandler := &RepoHandler{deps: deps} - csrf.GET("/repos", repoHandler.List) - csrf.GET("/repos/new", repoHandler.NewForm) - csrf.POST("/repos", repoHandler.Create) - csrf.GET("/repos/:id/edit", repoHandler.EditForm) - csrf.POST("/repos/:id", repoHandler.Update) - } - - return r + repoHandler := &RepoHandler{deps: deps} + g.GET("/repos", repoHandler.List) + g.GET("/repos/new", repoHandler.NewForm) + g.POST("/repos", repoHandler.Create) + g.GET("/repos/:id/edit", repoHandler.EditForm) + g.POST("/repos/:id", repoHandler.Update) } diff --git a/internal/handlers/admin/tickets.go b/internal/handlers/admin/tickets.go index c418b4b..7f6cb33 100644 --- a/internal/handlers/admin/tickets.go +++ b/internal/handlers/admin/tickets.go @@ -338,5 +338,5 @@ func (h *TicketHandler) UpdateStatus(c *gin.Context) { h.deps.ForgejoClient.RemoveLabel(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, inProgressLabel.ID) } - c.Redirect(http.StatusSeeOther, "/tickets/"+ticketID.String()) + c.Redirect(http.StatusSeeOther, "/admin/tickets/"+ticketID.String()) } diff --git a/internal/handlers/admin/users.go b/internal/handlers/admin/users.go index 3ccc7a5..c072c53 100644 --- a/internal/handlers/admin/users.go +++ b/internal/handlers/admin/users.go @@ -167,7 +167,7 @@ func (h *UserHandler) Create(c *gin.Context) { log.Error().Err(err).Msg("send welcome email error") } - c.Redirect(http.StatusSeeOther, "/users/"+user.ID.String()) + c.Redirect(http.StatusSeeOther, "/admin/users/"+user.ID.String()) } func (h *UserHandler) PendingList(c *gin.Context) { @@ -203,7 +203,7 @@ func (h *UserHandler) Approve(c *gin.Context) { } middleware.SetFlash(c, "success", "User "+user.Email+" has been approved") - c.Redirect(http.StatusSeeOther, "/users/pending") + c.Redirect(http.StatusSeeOther, "/admin/users/pending") } func (h *UserHandler) Reject(c *gin.Context) { @@ -220,7 +220,7 @@ func (h *UserHandler) Reject(c *gin.Context) { } middleware.SetFlash(c, "success", "User request has been rejected") - c.Redirect(http.StatusSeeOther, "/users/pending") + c.Redirect(http.StatusSeeOther, "/admin/users/pending") } func (h *UserHandler) UpdateRepos(c *gin.Context) { @@ -252,5 +252,5 @@ func (h *UserHandler) UpdateRepos(c *gin.Context) { } middleware.SetFlash(c, "success", "Project assignments updated") - c.Redirect(http.StatusSeeOther, "/users/"+userID.String()) + c.Redirect(http.StatusSeeOther, "/admin/users/"+userID.String()) } diff --git a/internal/handlers/public/routes.go b/internal/handlers/public/routes.go index 1db9612..2a38eef 100644 --- a/internal/handlers/public/routes.go +++ b/internal/handlers/public/routes.go @@ -10,6 +10,7 @@ import ( "github.com/mattnite/forgejo-tickets/internal/config" "github.com/mattnite/forgejo-tickets/internal/email" "github.com/mattnite/forgejo-tickets/internal/forgejo" + adminhandlers "github.com/mattnite/forgejo-tickets/internal/handlers/admin" "github.com/mattnite/forgejo-tickets/internal/middleware" "github.com/mattnite/forgejo-tickets/internal/templates" "gorm.io/gorm" @@ -88,6 +89,18 @@ func NewRouter(deps Dependencies) *gin.Engine { authenticated.GET("/tickets/:id/assets/:attachmentId/*filename", ticketHandler.DownloadIssueAttachment) authenticated.GET("/tickets/:id/comments/:commentId/assets/:attachmentId/*filename", ticketHandler.DownloadCommentAttachment) } + + adminGroup := csrf.Group("/admin") + adminGroup.Use(auth.RequireAdmin) + adminhandlers.RegisterRoutes(adminGroup, adminhandlers.Dependencies{ + DB: deps.DB, + Renderer: deps.Renderer, + Auth: deps.Auth, + SessionStore: deps.SessionStore, + EmailClient: deps.EmailClient, + ForgejoClient: deps.ForgejoClient, + Config: deps.Config, + }) } return r diff --git a/internal/models/models.go b/internal/models/models.go index fa1274b..b78eee3 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,9 +1,11 @@ package models import ( + "os" "time" "github.com/google/uuid" + "github.com/rs/zerolog/log" "gorm.io/gorm" ) @@ -19,12 +21,17 @@ type User struct { Email string `gorm:"uniqueIndex;not null" json:"email"` PasswordHash *string `json:"-"` Name string `gorm:"not null" json:"name"` + Role string `gorm:"not null;default:'customer'" json:"role"` EmailVerified bool `gorm:"not null;default:false" json:"email_verified"` Approved bool `gorm:"not null;default:false" json:"approved"` CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"` } +func (u *User) IsAdmin() bool { + return u.Role == "admin" +} + type UserRepo struct { UserID uuid.UUID `gorm:"type:uuid;not null;primaryKey"` RepoID uuid.UUID `gorm:"type:uuid;not null;primaryKey"` @@ -131,5 +138,16 @@ func AutoMigrate(db *gorm.DB) error { // Approve all existing verified users so they aren't locked out db.Exec("UPDATE users SET approved = true WHERE approved = false AND email_verified = true") + // Set default role for existing users that have NULL or empty role + db.Exec("UPDATE users SET role = 'customer' WHERE role IS NULL OR role = ''") + + // Bootstrap initial admin from environment variable + if adminEmail := os.Getenv("INITIAL_ADMIN_EMAIL"); adminEmail != "" { + result := db.Model(&User{}).Where("email = ?", adminEmail).Update("role", "admin") + if result.RowsAffected > 0 { + log.Info().Str("email", adminEmail).Msg("promoted user to admin") + } + } + return nil } diff --git a/web/templates/layouts/admin.html b/web/templates/layouts/admin.html index c153dcb..e53bc04 100644 --- a/web/templates/layouts/admin.html +++ b/web/templates/layouts/admin.html @@ -8,24 +8,7 @@