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 4c21b03..3294779 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 1f264ed..102fdfb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -14,7 +14,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" @@ -62,7 +61,7 @@ func main() { go sessionStore.Cleanup(ctx, 30*time.Minute) - publicRouter := publichandlers.NewRouter(publichandlers.Dependencies{ + router := publichandlers.NewRouter(publichandlers.Dependencies{ DB: db, Renderer: renderer, Auth: authService, @@ -72,60 +71,33 @@ func main() { Config: cfg, }) - adminRouter := adminhandlers.NewRouter(adminhandlers.Dependencies{ - DB: db, - Renderer: renderer, - Auth: authService, - 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 e86b0a9..ac7bc00 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,7 +3,6 @@ package config import ( "fmt" "os" - "strings" ) type Config struct { @@ -12,7 +11,6 @@ type Config struct { // Server PublicAddr string - AdminAddr string BaseURL string // Sessions @@ -42,14 +40,13 @@ type Config struct { AppleKeyPath string // Admin - TailscaleAllowedUsers []string + InitialAdminEmail string } 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,12 +64,7 @@ func Load() (*Config, error) { AppleKeyPath: getEnv("APPLE_KEY_PATH", ""), } - if allowed := getEnv("TAILSCALE_ALLOWED_USERS", ""); allowed != "" { - cfg.TailscaleAllowedUsers = strings.Split(allowed, ",") - for i := range cfg.TailscaleAllowedUsers { - cfg.TailscaleAllowedUsers[i] = strings.TrimSpace(cfg.TailscaleAllowedUsers[i]) - } - } + cfg.InitialAdminEmail = getEnv("INITIAL_ADMIN_EMAIL", "") if cfg.DatabaseURL == "" { return nil, fmt.Errorf("DATABASE_URL is required") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e59d1f8..a22cd3c 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", "my-secret") t.Setenv("PUBLIC_ADDR", ":9090") - t.Setenv("ADMIN_ADDR", ":9091") t.Setenv("BASE_URL", "https://example.com") cfg, err := Load() @@ -113,49 +109,23 @@ 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_TailscaleAllowedUsers(t *testing.T) { +func TestLoad_InitialAdminEmail(t *testing.T) { clearConfigEnv(t) t.Setenv("DATABASE_URL", "postgres://localhost/test") t.Setenv("SESSION_SECRET", "my-secret") - t.Setenv("TAILSCALE_ALLOWED_USERS", "alice@example.com, bob@example.com , charlie@example.com") + t.Setenv("INITIAL_ADMIN_EMAIL", "admin@example.com") cfg, err := Load() if err != nil { t.Fatalf("expected no error, got: %v", err) } - if len(cfg.TailscaleAllowedUsers) != 3 { - t.Fatalf("expected 3 tailscale users, got %d", len(cfg.TailscaleAllowedUsers)) - } - - expected := []string{"alice@example.com", "bob@example.com", "charlie@example.com"} - for i, want := range expected { - if cfg.TailscaleAllowedUsers[i] != want { - t.Errorf("TailscaleAllowedUsers[%d]: expected %q, got %q", i, want, cfg.TailscaleAllowedUsers[i]) - } - } -} - -func TestLoad_EmptyTailscaleAllowedUsers(t *testing.T) { - clearConfigEnv(t) - t.Setenv("DATABASE_URL", "postgres://localhost/test") - t.Setenv("SESSION_SECRET", "my-secret") - // TAILSCALE_ALLOWED_USERS not set - - cfg, err := Load() - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - if cfg.TailscaleAllowedUsers != nil { - t.Errorf("expected nil TailscaleAllowedUsers, got %v", cfg.TailscaleAllowedUsers) + if cfg.InitialAdminEmail != "admin@example.com" { + t.Errorf("expected InitialAdminEmail %q, got %q", "admin@example.com", cfg.InitialAdminEmail) } } 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 1785be9..4b3eb26 100644 --- a/internal/handlers/admin/routes.go +++ b/internal/handlers/admin/routes.go @@ -6,7 +6,6 @@ import ( "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" ) @@ -15,45 +14,35 @@ type Dependencies struct { DB *gorm.DB Renderer *templates.Renderer Auth *auth.Service + SessionStore *auth.PGStore EmailClient *email.Client ForgejoClient *forgejo.Client Config *config.Config } -func NewRouter(deps Dependencies) *gin.Engine { - r := gin.New() - - r.Use(middleware.RequestID) - r.Use(middleware.Logging) - r.Use(middleware.Recovery) - - tsAuth := &TailscaleAuth{allowedUsers: deps.Config.TailscaleAllowedUsers} - r.Use(tsAuth.Middleware) - +func RegisterRoutes(g *gin.RouterGroup, deps Dependencies) { dashboardHandler := &DashboardHandler{deps: deps} - r.GET("/", dashboardHandler.Index) + g.GET("/", dashboardHandler.Index) userHandler := &UserHandler{deps: deps} - r.GET("/users", userHandler.List) - r.GET("/users/pending", userHandler.PendingList) - r.GET("/users/new", userHandler.NewForm) - r.GET("/users/:id", userHandler.Detail) - r.POST("/users", userHandler.Create) - r.POST("/users/:id/approve", userHandler.Approve) - r.POST("/users/:id/reject", userHandler.Reject) - r.POST("/users/:id/repos", userHandler.UpdateRepos) + 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) ticketHandler := &TicketHandler{deps: deps} - r.GET("/tickets", ticketHandler.List) - r.GET("/tickets/:id", ticketHandler.Detail) - r.POST("/tickets/:id/status", ticketHandler.UpdateStatus) + g.GET("/tickets", ticketHandler.List) + g.GET("/tickets/:id", ticketHandler.Detail) + g.POST("/tickets/:id/status", ticketHandler.UpdateStatus) repoHandler := &RepoHandler{deps: deps} - r.GET("/repos", repoHandler.List) - r.GET("/repos/new", repoHandler.NewForm) - r.POST("/repos", repoHandler.Create) - r.GET("/repos/:id/edit", repoHandler.EditForm) - r.POST("/repos/:id", repoHandler.Update) - - return r + 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 d7328cf..f8c8a09 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) { @@ -202,7 +202,7 @@ func (h *UserHandler) Approve(c *gin.Context) { log.Error().Err(err).Msg("send approval email error") } - redirectURL := "/users/pending?" + url.Values{ + redirectURL := "/admin/users/pending?" + url.Values{ "flash": {"User " + user.Email + " has been approved"}, "flash_type": {"success"}, }.Encode() @@ -222,7 +222,7 @@ func (h *UserHandler) Reject(c *gin.Context) { return } - redirectURL := "/users/pending?" + url.Values{ + redirectURL := "/admin/users/pending?" + url.Values{ "flash": {"User request has been rejected"}, "flash_type": {"success"}, }.Encode() @@ -257,7 +257,7 @@ func (h *UserHandler) UpdateRepos(c *gin.Context) { h.deps.DB.Create(&models.UserRepo{UserID: userID, RepoID: repoID}) } - redirectURL := "/users/" + userID.String() + "?" + url.Values{ + redirectURL := "/admin/users/" + userID.String() + "?" + url.Values{ "flash": {"Project assignments updated"}, "flash_type": {"success"}, }.Encode() diff --git a/internal/handlers/public/routes.go b/internal/handlers/public/routes.go index 81514d6..beccad6 100644 --- a/internal/handlers/public/routes.go +++ b/internal/handlers/public/routes.go @@ -9,6 +9,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" @@ -83,6 +84,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 @@
- + {{template "nav" .}} {{template "flash" .}}
{{block "content" .}}{{end}} diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html index debf7f3..d8dc45f 100644 --- a/web/templates/layouts/base.html +++ b/web/templates/layouts/base.html @@ -10,7 +10,7 @@
{{template "nav" .}} {{template "flash" .}} -
+
{{block "content" .}}{{end}}
diff --git a/web/templates/pages/admin/repos/edit.html b/web/templates/pages/admin/repos/edit.html index c353ed3..ae814c5 100644 --- a/web/templates/pages/admin/repos/edit.html +++ b/web/templates/pages/admin/repos/edit.html @@ -4,7 +4,7 @@ {{with .Data}}

Edit Repo

@@ -63,7 +63,8 @@
{{end}} -
+ +

Repos

- Add Repo + Add Repo
{{with .Data}} @@ -39,7 +39,7 @@ {{end}} - Edit + Edit {{end}} diff --git a/web/templates/pages/admin/repos/new.html b/web/templates/pages/admin/repos/new.html index 6e30f7e..6c74f06 100644 --- a/web/templates/pages/admin/repos/new.html +++ b/web/templates/pages/admin/repos/new.html @@ -3,7 +3,7 @@ {{define "content"}}

Add Repo

@@ -16,7 +16,8 @@ {{end}} {{end}} - + +
- ← Back to tickets + ← Back to tickets
@@ -59,7 +59,8 @@
- + +
{{range .AllRepos}}