From 4fa62fc16478509f6a1e7f17990f64b3b89f0ad0 Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Thu, 12 Feb 2026 15:00:17 -0800 Subject: [PATCH] Init --- .dockerignore | 5 + .env.example | 36 +++ .gitignore | 30 +++ Dockerfile | 26 +++ Makefile | 32 +++ cmd/server/main.go | 122 +++++++++++ go.mod | 59 +++++ go.sum | 137 ++++++++++++ internal/auth/apple.go | 153 +++++++++++++ internal/auth/auth.go | 117 ++++++++++ internal/auth/oauth.go | 152 +++++++++++++ internal/auth/session.go | 75 +++++++ internal/auth/store.go | 158 ++++++++++++++ internal/auth/tokens.go | 91 ++++++++ internal/auth/tokens_test.go | 53 +++++ internal/config/config.go | 92 ++++++++ internal/config/config_test.go | 161 ++++++++++++++ internal/database/database.go | 34 +++ internal/email/email.go | 106 +++++++++ internal/email/templates.go | 67 ++++++ internal/forgejo/client.go | 116 ++++++++++ internal/forgejo/client_test.go | 167 ++++++++++++++ internal/forgejo/webhook.go | 52 +++++ internal/forgejo/webhook_test.go | 124 +++++++++++ internal/handlers/admin/auth.go | 71 ++++++ internal/handlers/admin/dashboard.go | 46 ++++ internal/handlers/admin/repos.go | 127 +++++++++++ internal/handlers/admin/routes.go | 53 +++++ internal/handlers/admin/tickets.go | 104 +++++++++ internal/handlers/admin/users.go | 98 +++++++++ internal/handlers/public/auth.go | 202 +++++++++++++++++ internal/handlers/public/home.go | 13 ++ internal/handlers/public/oauth.go | 206 ++++++++++++++++++ internal/handlers/public/routes.go | 84 +++++++ internal/handlers/public/tickets.go | 201 +++++++++++++++++ internal/handlers/public/webhook.go | 70 ++++++ internal/middleware/csrf.go | 30 +++ internal/middleware/middleware.go | 46 ++++ internal/middleware/middleware_test.go | 132 +++++++++++ internal/models/models.go | 127 +++++++++++ internal/models/models_test.go | 69 ++++++ internal/templates/funcs.go | 48 ++++ internal/templates/render.go | 140 ++++++++++++ package-lock.json | 19 ++ package.json | 5 + web/static/css/input.css | 1 + web/templates/layouts/admin.html | 31 +++ web/templates/layouts/base.html | 18 ++ web/templates/pages/admin/dashboard.html | 26 +++ web/templates/pages/admin/repos/edit.html | 61 ++++++ web/templates/pages/admin/repos/list.html | 47 ++++ web/templates/pages/admin/repos/new.html | 66 ++++++ web/templates/pages/admin/tickets/detail.html | 61 ++++++ web/templates/pages/admin/tickets/list.html | 45 ++++ web/templates/pages/admin/users/detail.html | 57 +++++ web/templates/pages/admin/users/list.html | 41 ++++ web/templates/pages/admin/users/new.html | 38 ++++ web/templates/pages/forgot-password.html | 32 +++ web/templates/pages/home.html | 17 ++ web/templates/pages/login.html | 51 +++++ web/templates/pages/register.html | 52 +++++ web/templates/pages/reset-password.html | 34 +++ web/templates/pages/tickets/detail.html | 61 ++++++ web/templates/pages/tickets/list.html | 42 ++++ web/templates/pages/tickets/new.html | 52 +++++ web/templates/partials/flash.html | 19 ++ web/templates/partials/nav.html | 23 ++ 67 files changed, 4931 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/apple.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/oauth.go create mode 100644 internal/auth/session.go create mode 100644 internal/auth/store.go create mode 100644 internal/auth/tokens.go create mode 100644 internal/auth/tokens_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/database/database.go create mode 100644 internal/email/email.go create mode 100644 internal/email/templates.go create mode 100644 internal/forgejo/client.go create mode 100644 internal/forgejo/client_test.go create mode 100644 internal/forgejo/webhook.go create mode 100644 internal/forgejo/webhook_test.go create mode 100644 internal/handlers/admin/auth.go create mode 100644 internal/handlers/admin/dashboard.go create mode 100644 internal/handlers/admin/repos.go create mode 100644 internal/handlers/admin/routes.go create mode 100644 internal/handlers/admin/tickets.go create mode 100644 internal/handlers/admin/users.go create mode 100644 internal/handlers/public/auth.go create mode 100644 internal/handlers/public/home.go create mode 100644 internal/handlers/public/oauth.go create mode 100644 internal/handlers/public/routes.go create mode 100644 internal/handlers/public/tickets.go create mode 100644 internal/handlers/public/webhook.go create mode 100644 internal/middleware/csrf.go create mode 100644 internal/middleware/middleware.go create mode 100644 internal/middleware/middleware_test.go create mode 100644 internal/models/models.go create mode 100644 internal/models/models_test.go create mode 100644 internal/templates/funcs.go create mode 100644 internal/templates/render.go create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 web/static/css/input.css create mode 100644 web/templates/layouts/admin.html create mode 100644 web/templates/layouts/base.html create mode 100644 web/templates/pages/admin/dashboard.html create mode 100644 web/templates/pages/admin/repos/edit.html create mode 100644 web/templates/pages/admin/repos/list.html create mode 100644 web/templates/pages/admin/repos/new.html create mode 100644 web/templates/pages/admin/tickets/detail.html create mode 100644 web/templates/pages/admin/tickets/list.html create mode 100644 web/templates/pages/admin/users/detail.html create mode 100644 web/templates/pages/admin/users/list.html create mode 100644 web/templates/pages/admin/users/new.html create mode 100644 web/templates/pages/forgot-password.html create mode 100644 web/templates/pages/home.html create mode 100644 web/templates/pages/login.html create mode 100644 web/templates/pages/register.html create mode 100644 web/templates/pages/reset-password.html create mode 100644 web/templates/pages/tickets/detail.html create mode 100644 web/templates/pages/tickets/list.html create mode 100644 web/templates/pages/tickets/new.html create mode 100644 web/templates/partials/flash.html create mode 100644 web/templates/partials/nav.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..175c481 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.env +.claude +*.md +forgejo-tickets diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ab9e1e3 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Database +DATABASE_URL=postgres://user:password@localhost:5432/forgejo_tickets?sslmode=disable + +# Server +PUBLIC_ADDR=:8080 +ADMIN_ADDR=:8081 +BASE_URL=http://localhost:8080 + +# Sessions (generate with: openssl rand -hex 32) +SESSION_SECRET=change-me-to-a-random-secret + +# Forgejo +FORGEJO_URL=https://forgejo.example.com +FORGEJO_API_TOKEN=your-forgejo-api-token + +# Postmark +POSTMARK_SERVER_TOKEN=your-postmark-server-token +POSTMARK_FROM_EMAIL=support@example.com + +# OAuth - Google +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# OAuth - Microsoft +MICROSOFT_CLIENT_ID= +MICROSOFT_CLIENT_SECRET= +MICROSOFT_TENANT_ID=common + +# OAuth - Apple +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_KEY_PATH= + +# Admin (comma-separated Tailscale login names) +TAILSCALE_ALLOWED_USERS=user@example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63c9b33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Binary +forgejo-tickets + +# Environment +.env + +# Tailwind generated CSS +web/static/css/output.css + +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +vendor/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +node_modules diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f0c4559 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Stage 1: Build Tailwind CSS +FROM node:22-alpine AS tailwind +WORKDIR /app +COPY web/static/css/input.css web/static/css/input.css +COPY web/templates/ web/templates/ +COPY internal/handlers/ internal/handlers/ +RUN npx @tailwindcss/cli -i web/static/css/input.css -o web/static/css/output.css --minify + +# Stage 2: Build Go binary +FROM golang:1.23-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=tailwind /app/web/static/css/output.css web/static/css/output.css +RUN CGO_ENABLED=0 GOOS=linux go build -o forgejo-tickets ./cmd/server + +# Stage 3: Minimal runtime +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +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 +CMD ["./forgejo-tickets"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..818e5e8 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +APP_NAME := forgejo-tickets +REGISTRY := registry.ts.mattnite.net +IMAGE := $(REGISTRY)/$(APP_NAME) +GIT_SHA := $(shell git rev-parse HEAD) + +.PHONY: build run test tailwind tailwind-watch docker docker-push clean + +build: tailwind + go build -o $(APP_NAME) ./cmd/server + +run: + go run ./cmd/server + +test: + go test ./... + +tailwind: + npx @tailwindcss/cli -i web/static/css/input.css -o web/static/css/output.css --minify + +tailwind-watch: + npx @tailwindcss/cli -i web/static/css/input.css -o web/static/css/output.css --watch + +docker: + docker build -t $(IMAGE):$(GIT_SHA) -t $(IMAGE):latest . + +docker-push: docker + docker push $(IMAGE):$(GIT_SHA) + docker push $(IMAGE):latest + +clean: + rm -f $(APP_NAME) + rm -f web/static/css/output.css diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..2659d4e --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "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/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" + "github.com/rs/zerolog/log" +) + +func main() { + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + gin.SetMode(gin.ReleaseMode) + + cfg, err := config.Load() + if err != nil { + log.Fatal().Msgf("failed to load config: %v", err) + } + + db, err := database.Connect(cfg.DatabaseURL) + if err != nil { + log.Fatal().Msgf("failed to connect to database: %v", err) + } + + if err := database.RunMigrations(db); err != nil { + log.Fatal().Msgf("failed to run migrations: %v", err) + } + + renderer, err := templates.NewRenderer() + if err != nil { + log.Fatal().Msgf("failed to initialize templates: %v", err) + } + + emailClient := email.NewClient(cfg.PostmarkServerToken, cfg.PostmarkFromEmail, cfg.BaseURL) + forgejoClient := forgejo.NewClient(cfg.ForgejoURL, cfg.ForgejoAPIToken) + sessionStore := auth.NewPGStore(db, []byte(cfg.SessionSecret)) + authService := auth.NewService(db, sessionStore, emailClient) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go sessionStore.Cleanup(ctx, 30*time.Minute) + + publicRouter := publichandlers.NewRouter(publichandlers.Dependencies{ + DB: db, + Renderer: renderer, + Auth: authService, + SessionStore: sessionStore, + EmailClient: emailClient, + ForgejoClient: forgejoClient, + Config: cfg, + }) + + adminRouter := adminhandlers.NewRouter(adminhandlers.Dependencies{ + DB: db, + Renderer: renderer, + Auth: authService, + EmailClient: emailClient, + Config: cfg, + }) + + publicServer := &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, + 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) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Info().Msg("Shutting down servers...") + + 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") + } + + cancel() + log.Info().Msg("Servers stopped") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4242549 --- /dev/null +++ b/go.mod @@ -0,0 +1,59 @@ +module github.com/mattnite/forgejo-tickets + +go 1.23.0 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/gorilla/csrf v1.7.2 + github.com/gorilla/securecookie v1.1.2 + github.com/gorilla/sessions v1.4.0 + github.com/mrz1836/postmark v1.6.5 + github.com/rs/zerolog v1.34.0 + golang.org/x/crypto v0.40.0 + golang.org/x/oauth2 v0.25.0 + gorm.io/driver/postgres v1.5.11 + gorm.io/gorm v1.25.12 +) + +require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..69e847b --- /dev/null +++ b/go.sum @@ -0,0 +1,137 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= +github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mrz1836/postmark v1.6.5 h1:FSlysQmx9n4NnU4IsvZ0nN+rylNqPHvqYcHtuSk9yi8= +github.com/mrz1836/postmark v1.6.5/go.mod h1:6z5MxAH00Kj44owtQaryv9Pbqp5OKT3wWcRSydB0p0A= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +goji.io v2.0.2+incompatible h1:uIssv/elbKRLznFUy3Xj4+2Mz/qKhek/9aZQDUMae7c= +goji.io v2.0.2+incompatible/go.mod h1:sbqFwrtqZACxLBTQcdgVjFh54yGVCvwq8+w49MVMMIk= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= +golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/internal/auth/apple.go b/internal/auth/apple.go new file mode 100644 index 0000000..cbf8e1d --- /dev/null +++ b/internal/auth/apple.go @@ -0,0 +1,153 @@ +package auth + +import ( + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/oauth2" +) + +type AppleProvider struct { + OAuthProvider + teamID string + keyID string + keyPath string +} + +func NewAppleProvider(clientID, teamID, keyID, keyPath, redirectURL string) (*AppleProvider, error) { + p := &AppleProvider{ + OAuthProvider: OAuthProvider{ + Name: "apple", + Config: &oauth2.Config{ + ClientID: clientID, + RedirectURL: redirectURL, + Scopes: []string{"name", "email"}, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://appleid.apple.com/auth/authorize", + TokenURL: "https://appleid.apple.com/auth/token", + }, + }, + }, + teamID: teamID, + keyID: keyID, + keyPath: keyPath, + } + + p.OAuthProvider.UserInfo = p.getUserInfo + return p, nil +} + +func (p *AppleProvider) GenerateClientSecret() (string, error) { + keyData, err := os.ReadFile(p.keyPath) + if err != nil { + return "", fmt.Errorf("read apple key: %w", err) + } + + block, _ := pem.Decode(keyData) + if block == nil { + return "", fmt.Errorf("failed to decode PEM block") + } + + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("parse private key: %w", err) + } + + now := time.Now() + claims := jwt.MapClaims{ + "iss": p.teamID, + "iat": now.Unix(), + "exp": now.Add(5 * time.Minute).Unix(), + "aud": "https://appleid.apple.com", + "sub": p.Config.ClientID, + } + + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["kid"] = p.keyID + + return token.SignedString(key) +} + +func (p *AppleProvider) getUserInfo(ctx context.Context, token *oauth2.Token) (*OAuthUserInfo, error) { + idToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, fmt.Errorf("missing id_token") + } + + // Parse without verification since we already got the token from Apple + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + parsed, _, err := parser.ParseUnverified(idToken, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("parse id_token: %w", err) + } + + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("invalid claims") + } + + sub, _ := claims["sub"].(string) + email, _ := claims["email"].(string) + + return &OAuthUserInfo{ + ProviderUserID: sub, + Email: email, + Name: email, // Apple may not provide name in id_token + }, nil +} + +func (p *AppleProvider) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) { + secret, err := p.GenerateClientSecret() + if err != nil { + return nil, err + } + + p.Config.ClientSecret = secret + return p.Config.Exchange(ctx, code) +} + +// ParseAppleUserData parses the user data from Apple's form_post callback. +// Apple only sends user data on the first authorization. +type AppleUserData struct { + Name *struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + } `json:"name"` + Email string `json:"email"` +} + +func ParseAppleUserData(data string) (*AppleUserData, error) { + if data == "" { + return nil, nil + } + var ud AppleUserData + if err := json.Unmarshal([]byte(data), &ud); err != nil { + return nil, err + } + return &ud, nil +} + +// Ensure the id_token response is read +func init() { + // Register a custom AuthCodeOption to request response_mode=form_post + _ = oauth2.SetAuthURLParam("response_mode", "form_post") +} + +// AppleAuthCodeOption returns the extra auth URL param for form_post mode. +func AppleAuthCodeOption() oauth2.AuthCodeOption { + return oauth2.SetAuthURLParam("response_mode", "form_post") +} + +// ReadAppleFormPost reads an Apple Sign In form_post callback. +func ReadAppleFormPost(body io.Reader) (code, state, userData string, err error) { + // Apple sends form_post so we need to read from the body + // This is handled by r.FormValue() in the handler + return "", "", "", nil +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..703cea5 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,117 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mattnite/forgejo-tickets/internal/email" + "github.com/mattnite/forgejo-tickets/internal/models" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type Service struct { + db *gorm.DB + store *PGStore + email *email.Client +} + +func NewService(db *gorm.DB, store *PGStore, emailClient *email.Client) *Service { + return &Service{ + db: db, + store: store, + email: emailClient, + } +} + +func (s *Service) Register(ctx context.Context, emailAddr, password, name string) (*models.User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + hashStr := string(hash) + user := models.User{ + Email: emailAddr, + PasswordHash: &hashStr, + Name: name, + EmailVerified: false, + } + + if err := s.db.WithContext(ctx).Create(&user).Error; err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + + return &user, nil +} + +func (s *Service) Login(ctx context.Context, emailAddr, password string) (*models.User, error) { + var user models.User + if err := s.db.WithContext(ctx).Where("email = ?", emailAddr).First(&user).Error; err != nil { + return nil, fmt.Errorf("invalid email or password") + } + + if user.PasswordHash == nil { + return nil, fmt.Errorf("this account uses social login") + } + + if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil { + return nil, fmt.Errorf("invalid email or password") + } + + if !user.EmailVerified { + return nil, fmt.Errorf("please verify your email before logging in") + } + + return &user, nil +} + +func (s *Service) CreateSession(r *http.Request, w http.ResponseWriter, userID uuid.UUID) error { + session, err := s.store.Get(r, sessionCookieName) + if err != nil { + session, err = s.store.New(r, sessionCookieName) + if err != nil { + return err + } + } + + session.Values["user_id"] = userID.String() + return s.store.Save(r, w, session) +} + +func (s *Service) DestroySession(r *http.Request, w http.ResponseWriter) error { + session, err := s.store.Get(r, sessionCookieName) + if err != nil { + return nil + } + + session.Options.MaxAge = -1 + return s.store.Save(r, w, session) +} + +func (s *Service) CreateUserWithPassword(ctx context.Context, emailAddr, password, name string, verified bool) (*models.User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + hashStr := string(hash) + user := models.User{ + Email: emailAddr, + PasswordHash: &hashStr, + Name: name, + EmailVerified: verified, + } + + if err := s.db.WithContext(ctx).Create(&user).Error; err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + + return &user, nil +} + +func (s *Service) DB() *gorm.DB { + return s.db +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go new file mode 100644 index 0000000..5f92d0e --- /dev/null +++ b/internal/auth/oauth.go @@ -0,0 +1,152 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/mattnite/forgejo-tickets/internal/models" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "golang.org/x/oauth2/microsoft" +) + +type OAuthProvider struct { + Name string + Config *oauth2.Config + UserInfo func(ctx context.Context, token *oauth2.Token) (*OAuthUserInfo, error) +} + +type OAuthUserInfo struct { + ProviderUserID string + Email string + Name string +} + +func NewGoogleProvider(clientID, clientSecret, redirectURL string) *OAuthProvider { + return &OAuthProvider{ + Name: "google", + Config: &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: []string{"openid", "email", "profile"}, + Endpoint: google.Endpoint, + }, + UserInfo: func(ctx context.Context, token *oauth2.Token) (*OAuthUserInfo, error) { + client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)) + resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var data struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + } + if err := json.Unmarshal(body, &data); err != nil { + return nil, err + } + + return &OAuthUserInfo{ + ProviderUserID: data.ID, + Email: data.Email, + Name: data.Name, + }, nil + }, + } +} + +func NewMicrosoftProvider(clientID, clientSecret, tenantID, redirectURL string) *OAuthProvider { + return &OAuthProvider{ + Name: "microsoft", + Config: &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: []string{"openid", "email", "profile", "User.Read"}, + Endpoint: microsoft.AzureADEndpoint(tenantID), + }, + UserInfo: func(ctx context.Context, token *oauth2.Token) (*OAuthUserInfo, error) { + client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)) + resp, err := client.Get("https://graph.microsoft.com/v1.0/me") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var data struct { + ID string `json:"id"` + Mail string `json:"mail"` + Name string `json:"displayName"` + } + if err := json.Unmarshal(body, &data); err != nil { + return nil, err + } + + return &OAuthUserInfo{ + ProviderUserID: data.ID, + Email: data.Mail, + Name: data.Name, + }, nil + }, + } +} + +func (s *Service) FindOrCreateOAuthUser(ctx context.Context, provider string, info *OAuthUserInfo) (*models.User, error) { + // Try to find existing OAuth account + var oauthAccount models.OAuthAccount + if err := s.db.WithContext(ctx).Where("provider = ? AND provider_user_id = ?", provider, info.ProviderUserID).First(&oauthAccount).Error; err == nil { + var user models.User + if err := s.db.WithContext(ctx).First(&user, "id = ?", oauthAccount.UserID).Error; err != nil { + return nil, err + } + return &user, nil + } + + // Try to find existing user by email + var user models.User + if err := s.db.WithContext(ctx).Where("email = ?", info.Email).First(&user).Error; err != nil { + // Create new user + user = models.User{ + Email: info.Email, + Name: info.Name, + EmailVerified: true, + } + if err := s.db.WithContext(ctx).Create(&user).Error; err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + } + + // Link OAuth account + oauthAccount = models.OAuthAccount{ + UserID: user.ID, + Provider: provider, + ProviderUserID: info.ProviderUserID, + Email: info.Email, + } + if err := s.db.WithContext(ctx).Create(&oauthAccount).Error; err != nil { + return nil, fmt.Errorf("create oauth account: %w", err) + } + + // Mark email as verified for OAuth users + if !user.EmailVerified { + s.db.WithContext(ctx).Model(&user).Update("email_verified", true) + user.EmailVerified = true + } + + return &user, nil +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..a42b93b --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,75 @@ +package auth + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/mattnite/forgejo-tickets/internal/models" +) + +const userContextKey = "user" + +type requestContextKey string + +const userRequestContextKey requestContextKey = "user" + +func (s *Service) SessionMiddleware(c *gin.Context) { + session, err := s.store.Get(c.Request, sessionCookieName) + if err != nil || session.IsNew { + c.Next() + return + } + + userIDStr, ok := session.Values["user_id"].(string) + if !ok { + c.Next() + return + } + + userID, err := uuid.Parse(userIDStr) + if err != nil { + c.Next() + return + } + + var user models.User + if err := s.db.First(&user, "id = ?", userID).Error; err != nil { + c.Next() + return + } + + c.Set(userContextKey, &user) + // Also store on the request context so the template renderer can access it + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), userRequestContextKey, &user)) + c.Next() +} + +func RequireAuth(c *gin.Context) { + if CurrentUser(c) == nil { + c.Redirect(http.StatusSeeOther, "/login") + c.Abort() + return + } + c.Next() +} + +func CurrentUser(c *gin.Context) *models.User { + user, exists := c.Get(userContextKey) + if !exists { + return nil + } + u, ok := user.(*models.User) + if !ok { + return nil + } + return u +} + +// CurrentUserFromRequest retrieves the current user from the request context. +// Used by the template renderer which receives *http.Request instead of *gin.Context. +func CurrentUserFromRequest(r *http.Request) *models.User { + user, _ := r.Context().Value(userRequestContextKey).(*models.User) + return user +} diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 0000000..bff5e62 --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,158 @@ +package auth + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/gob" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "github.com/gorilla/securecookie" + "github.com/gorilla/sessions" + "github.com/mattnite/forgejo-tickets/internal/models" + "gorm.io/gorm" +) + +const ( + sessionCookieName = "session" + sessionMaxAge = 86400 * 30 // 30 days +) + +type PGStore struct { + db *gorm.DB + codecs []securecookie.Codec + options *sessions.Options +} + +func NewPGStore(db *gorm.DB, keyPairs ...[]byte) *PGStore { + return &PGStore{ + db: db, + codecs: securecookie.CodecsFromPairs(keyPairs...), + options: &sessions.Options{ + Path: "/", + MaxAge: sessionMaxAge, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }, + } +} + +func (s *PGStore) Get(r *http.Request, name string) (*sessions.Session, error) { + return sessions.GetRegistry(r).Get(s, name) +} + +func (s *PGStore) New(r *http.Request, name string) (*sessions.Session, error) { + session := sessions.NewSession(s, name) + session.Options = &sessions.Options{ + Path: s.options.Path, + MaxAge: s.options.MaxAge, + HttpOnly: s.options.HttpOnly, + SameSite: s.options.SameSite, + Secure: s.options.Secure, + } + session.IsNew = true + + cookie, err := r.Cookie(name) + if err != nil { + return session, nil + } + + err = securecookie.DecodeMulti(name, cookie.Value, &session.ID, s.codecs...) + if err != nil { + return session, nil + } + + var dbSession models.Session + result := s.db.Where("token = ? AND expires_at > ?", session.ID, time.Now()).First(&dbSession) + if result.Error != nil { + return session, nil + } + + if err := gob.NewDecoder(bytes.NewReader(dbSession.Data)).Decode(&session.Values); err != nil { + return session, nil + } + + session.Values["user_id"] = dbSession.UserID.String() + session.IsNew = false + return session, nil +} + +func (s *PGStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error { + if session.Options.MaxAge < 0 { + if session.ID != "" { + s.db.Where("token = ?", session.ID).Delete(&models.Session{}) + } + http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options)) + return nil + } + + if session.ID == "" { + token := make([]byte, 32) + if _, err := rand.Read(token); err != nil { + return err + } + session.ID = base64.URLEncoding.EncodeToString(token) + } + + userIDStr, _ := session.Values["user_id"].(string) + userID, err := uuid.Parse(userIDStr) + if err != nil { + return err + } + + valuesToEncode := make(map[interface{}]interface{}) + for k, v := range session.Values { + if k != "user_id" { + valuesToEncode[k] = v + } + } + + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(valuesToEncode); err != nil { + return err + } + + expiresAt := time.Now().Add(time.Duration(session.Options.MaxAge) * time.Second) + + dbSession := models.Session{ + Token: session.ID, + UserID: userID, + Data: buf.Bytes(), + ExpiresAt: expiresAt, + } + + result := s.db.Where("token = ?", session.ID).Assign(models.Session{ + Data: buf.Bytes(), + ExpiresAt: expiresAt, + }).FirstOrCreate(&dbSession) + if result.Error != nil { + return result.Error + } + + encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, s.codecs...) + if err != nil { + return err + } + + http.SetCookie(w, sessions.NewCookie(session.Name(), encoded, session.Options)) + return nil +} + +func (s *PGStore) Cleanup(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.db.Where("expires_at <= ?", time.Now()).Delete(&models.Session{}).Error; err != nil { + log.Error().Err(err).Msg("session cleanup error") + } + } + } +} diff --git a/internal/auth/tokens.go b/internal/auth/tokens.go new file mode 100644 index 0000000..ec8a438 --- /dev/null +++ b/internal/auth/tokens.go @@ -0,0 +1,91 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/mattnite/forgejo-tickets/internal/models" + "golang.org/x/crypto/bcrypt" +) + +func (s *Service) GenerateVerificationToken(ctx context.Context, userID uuid.UUID) (string, error) { + return s.generateToken(ctx, userID, models.TokenTypeVerifyEmail, 24*time.Hour) +} + +func (s *Service) GeneratePasswordResetToken(ctx context.Context, userID uuid.UUID) (string, error) { + return s.generateToken(ctx, userID, models.TokenTypeResetPassword, 1*time.Hour) +} + +func (s *Service) VerifyEmailToken(ctx context.Context, plainToken string) (*models.User, error) { + return s.redeemToken(ctx, plainToken, models.TokenTypeVerifyEmail, func(ctx context.Context, userID uuid.UUID) error { + return s.db.WithContext(ctx).Model(&models.User{}).Where("id = ?", userID).Update("email_verified", true).Error + }) +} + +func (s *Service) RedeemPasswordResetToken(ctx context.Context, plainToken, newPassword string) (*models.User, error) { + return s.redeemToken(ctx, plainToken, models.TokenTypeResetPassword, func(ctx context.Context, userID uuid.UUID) error { + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + hashStr := string(hash) + return s.db.WithContext(ctx).Model(&models.User{}).Where("id = ?", userID).Update("password_hash", hashStr).Error + }) +} + +func (s *Service) generateToken(ctx context.Context, userID uuid.UUID, tokenType models.TokenType, ttl time.Duration) (string, error) { + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return "", err + } + + plainToken := hex.EncodeToString(tokenBytes) + tokenHash := hashToken(plainToken) + + emailToken := models.EmailToken{ + UserID: userID, + TokenHash: tokenHash, + TokenType: tokenType, + ExpiresAt: time.Now().Add(ttl), + } + + if err := s.db.WithContext(ctx).Create(&emailToken).Error; err != nil { + return "", fmt.Errorf("create token: %w", err) + } + + return plainToken, nil +} + +func (s *Service) redeemToken(ctx context.Context, plainToken string, tokenType models.TokenType, action func(context.Context, uuid.UUID) error) (*models.User, error) { + tokenHash := hashToken(plainToken) + + var token models.EmailToken + if err := s.db.WithContext(ctx).Where("token_hash = ? AND token_type = ? AND expires_at > ? AND used_at IS NULL", + tokenHash, tokenType, time.Now()).First(&token).Error; err != nil { + return nil, fmt.Errorf("invalid or expired token") + } + + if err := action(ctx, token.UserID); err != nil { + return nil, err + } + + now := time.Now() + s.db.WithContext(ctx).Model(&token).Update("used_at", &now) + + var user models.User + if err := s.db.WithContext(ctx).First(&user, "id = ?", token.UserID).Error; err != nil { + return nil, err + } + + return &user, nil +} + +func hashToken(plainToken string) string { + h := sha256.Sum256([]byte(plainToken)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/auth/tokens_test.go b/internal/auth/tokens_test.go new file mode 100644 index 0000000..402888b --- /dev/null +++ b/internal/auth/tokens_test.go @@ -0,0 +1,53 @@ +package auth + +import ( + "encoding/hex" + "testing" +) + +func TestHashToken_Consistent(t *testing.T) { + token := "abc123def456" + hash1 := hashToken(token) + hash2 := hashToken(token) + + if hash1 != hash2 { + t.Errorf("hashToken is not consistent: got %q and %q for the same input", hash1, hash2) + } +} + +func TestHashToken_DifferentInputs(t *testing.T) { + hash1 := hashToken("token-one") + hash2 := hashToken("token-two") + + if hash1 == hash2 { + t.Errorf("hashToken produced identical hashes for different inputs: %q", hash1) + } +} + +func TestHashToken_ValidHexLength(t *testing.T) { + hash := hashToken("some-test-token") + + // SHA-256 produces 32 bytes = 64 hex characters + if len(hash) != 64 { + t.Errorf("expected hash length 64, got %d", len(hash)) + } + + // Verify it is valid hex + _, err := hex.DecodeString(hash) + if err != nil { + t.Errorf("hash is not valid hex: %v", err) + } +} + +func TestHashToken_EmptyInput(t *testing.T) { + hash := hashToken("") + + if len(hash) != 64 { + t.Errorf("expected hash length 64 for empty input, got %d", len(hash)) + } + + _, err := hex.DecodeString(hash) + if err != nil { + t.Errorf("hash of empty input is not valid hex: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e86b0a9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,92 @@ +package config + +import ( + "fmt" + "os" + "strings" +) + +type Config struct { + // Database + DatabaseURL string + + // Server + PublicAddr string + AdminAddr string + BaseURL string + + // Sessions + SessionSecret string + + // Forgejo + ForgejoURL string + ForgejoAPIToken string + + // Postmark + PostmarkServerToken string + PostmarkFromEmail string + + // OAuth - Google + GoogleClientID string + GoogleClientSecret string + + // OAuth - Microsoft + MicrosoftClientID string + MicrosoftClientSecret string + MicrosoftTenantID string + + // OAuth - Apple + AppleClientID string + AppleTeamID string + AppleKeyID string + AppleKeyPath string + + // Admin + TailscaleAllowedUsers []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", ""), + ForgejoAPIToken: getEnv("FORGEJO_API_TOKEN", ""), + PostmarkServerToken: getEnv("POSTMARK_SERVER_TOKEN", ""), + PostmarkFromEmail: getEnv("POSTMARK_FROM_EMAIL", ""), + GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""), + GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""), + MicrosoftClientID: getEnv("MICROSOFT_CLIENT_ID", ""), + MicrosoftClientSecret: getEnv("MICROSOFT_CLIENT_SECRET", ""), + MicrosoftTenantID: getEnv("MICROSOFT_TENANT_ID", "common"), + AppleClientID: getEnv("APPLE_CLIENT_ID", ""), + AppleTeamID: getEnv("APPLE_TEAM_ID", ""), + AppleKeyID: getEnv("APPLE_KEY_ID", ""), + 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]) + } + } + + if cfg.DatabaseURL == "" { + return nil, fmt.Errorf("DATABASE_URL is required") + } + if cfg.SessionSecret == "" { + return nil, fmt.Errorf("SESSION_SECRET is required") + } + + return cfg, nil +} + +func getEnv(key, fallback string) string { + if val := os.Getenv(key); val != "" { + return val + } + return fallback +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e59d1f8 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,161 @@ +package config + +import ( + "os" + "testing" +) + +// clearConfigEnv unsets all environment variables that Load() reads, +// so tests start from a clean slate. +func clearConfigEnv(t *testing.T) { + t.Helper() + envVars := []string{ + "DATABASE_URL", "PUBLIC_ADDR", "ADMIN_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", + } + for _, v := range envVars { + os.Unsetenv(v) + } +} + +func TestLoad_MissingDatabaseURL(t *testing.T) { + clearConfigEnv(t) + t.Setenv("SESSION_SECRET", "secret-value") + // DATABASE_URL is not set + + _, err := Load() + if err == nil { + t.Fatal("expected error when DATABASE_URL is missing, got nil") + } + + expected := "DATABASE_URL is required" + if err.Error() != expected { + t.Errorf("expected error %q, got %q", expected, err.Error()) + } +} + +func TestLoad_MissingSessionSecret(t *testing.T) { + clearConfigEnv(t) + t.Setenv("DATABASE_URL", "postgres://localhost/test") + // SESSION_SECRET is not set + + _, err := Load() + if err == nil { + t.Fatal("expected error when SESSION_SECRET is missing, got nil") + } + + expected := "SESSION_SECRET is required" + if err.Error() != expected { + t.Errorf("expected error %q, got %q", expected, err.Error()) + } +} + +func TestLoad_Success(t *testing.T) { + clearConfigEnv(t) + t.Setenv("DATABASE_URL", "postgres://localhost/test") + t.Setenv("SESSION_SECRET", "my-secret") + + cfg, err := Load() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if cfg.DatabaseURL != "postgres://localhost/test" { + t.Errorf("expected DatabaseURL %q, got %q", "postgres://localhost/test", cfg.DatabaseURL) + } + if cfg.SessionSecret != "my-secret" { + t.Errorf("expected SessionSecret %q, got %q", "my-secret", cfg.SessionSecret) + } +} + +func TestLoad_DefaultValues(t *testing.T) { + clearConfigEnv(t) + t.Setenv("DATABASE_URL", "postgres://localhost/test") + t.Setenv("SESSION_SECRET", "my-secret") + + cfg, err := Load() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + 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) + } + if cfg.MicrosoftTenantID != "common" { + t.Errorf("expected default MicrosoftTenantID %q, got %q", "common", cfg.MicrosoftTenantID) + } +} + +func TestLoad_OverrideDefaults(t *testing.T) { + clearConfigEnv(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() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + 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) { + 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") + + 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) + } +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..220b61f --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,34 @@ +package database + +import ( + "fmt" + + "github.com/mattnite/forgejo-tickets/internal/models" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func Connect(databaseURL string) (*gorm.DB, error) { + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) + if err != nil { + return nil, fmt.Errorf("connect to database: %w", err) + } + + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("get underlying sql.DB: %w", err) + } + + if err := sqlDB.Ping(); err != nil { + return nil, fmt.Errorf("ping database: %w", err) + } + + return db, nil +} + +func RunMigrations(db *gorm.DB) error { + return models.AutoMigrate(db) +} diff --git a/internal/email/email.go b/internal/email/email.go new file mode 100644 index 0000000..95f5849 --- /dev/null +++ b/internal/email/email.go @@ -0,0 +1,106 @@ +package email + +import ( + "context" + "fmt" + + "github.com/mrz1836/postmark" +) + +type Client struct { + server *postmark.Client + fromEmail string + baseURL string +} + +func NewClient(serverToken, fromEmail, baseURL string) *Client { + var server *postmark.Client + if serverToken != "" { + server = postmark.NewClient(serverToken, "") + } + return &Client{ + server: server, + fromEmail: fromEmail, + baseURL: baseURL, + } +} + +func (c *Client) SendVerificationEmail(to, name, token string) error { + if c.server == nil { + return fmt.Errorf("email client not configured") + } + + verifyURL := fmt.Sprintf("%s/verify-email?token=%s", c.baseURL, token) + htmlBody := renderVerificationEmail(name, verifyURL) + textBody := fmt.Sprintf("Hi %s,\n\nPlease verify your email by visiting: %s\n\nThis link expires in 24 hours.", name, verifyURL) + + _, err := c.server.SendEmail(context.Background(), postmark.Email{ + From: c.fromEmail, + To: to, + Subject: "Verify your email address", + HTMLBody: htmlBody, + TextBody: textBody, + Tag: "verification", + }) + return err +} + +func (c *Client) SendPasswordResetEmail(to, name, token string) error { + if c.server == nil { + return fmt.Errorf("email client not configured") + } + + resetURL := fmt.Sprintf("%s/reset-password?token=%s", c.baseURL, token) + htmlBody := renderPasswordResetEmail(name, resetURL) + textBody := fmt.Sprintf("Hi %s,\n\nReset your password by visiting: %s\n\nThis link expires in 1 hour. If you didn't request this, please ignore this email.", name, resetURL) + + _, err := c.server.SendEmail(context.Background(), postmark.Email{ + From: c.fromEmail, + To: to, + Subject: "Reset your password", + HTMLBody: htmlBody, + TextBody: textBody, + Tag: "password-reset", + }) + return err +} + +func (c *Client) SendTicketClosedNotification(to, name, ticketTitle, ticketID string) error { + if c.server == nil { + return fmt.Errorf("email client not configured") + } + + ticketURL := fmt.Sprintf("%s/tickets/%s", c.baseURL, ticketID) + htmlBody := renderTicketClosedEmail(name, ticketTitle, ticketURL) + textBody := fmt.Sprintf("Hi %s,\n\nYour ticket \"%s\" has been resolved.\n\nView it at: %s", name, ticketTitle, ticketURL) + + _, err := c.server.SendEmail(context.Background(), postmark.Email{ + From: c.fromEmail, + To: to, + Subject: fmt.Sprintf("Your ticket \"%s\" has been resolved", ticketTitle), + HTMLBody: htmlBody, + TextBody: textBody, + Tag: "ticket-closed", + }) + return err +} + +func (c *Client) SendWelcomeEmail(to, name, tempPassword string) error { + if c.server == nil { + return fmt.Errorf("email client not configured") + } + + loginURL := fmt.Sprintf("%s/login", c.baseURL) + htmlBody := renderWelcomeEmail(name, to, tempPassword, loginURL) + textBody := fmt.Sprintf("Hi %s,\n\nAn account has been created for you.\n\nEmail: %s\nTemporary Password: %s\n\nPlease log in at %s and change your password.", name, to, tempPassword, loginURL) + + _, err := c.server.SendEmail(context.Background(), postmark.Email{ + From: c.fromEmail, + To: to, + Subject: "Welcome - Your account has been created", + HTMLBody: htmlBody, + TextBody: textBody, + Tag: "welcome", + }) + return err +} diff --git a/internal/email/templates.go b/internal/email/templates.go new file mode 100644 index 0000000..9b693b0 --- /dev/null +++ b/internal/email/templates.go @@ -0,0 +1,67 @@ +package email + +import "fmt" + +func emailWrapper(content string) string { + return fmt.Sprintf(` + + + +%s +
+

This is an automated message. Please do not reply directly to this email.

+ +`, content) +} + +func renderVerificationEmail(name, verifyURL string) string { + return emailWrapper(fmt.Sprintf(` +

Verify your email address

+

Hi %s,

+

Please verify your email address by clicking the button below:

+

+ Verify Email +

+

Or copy and paste this link into your browser:

+

%s

+

This link expires in 24 hours.

`, name, verifyURL, verifyURL)) +} + +func renderPasswordResetEmail(name, resetURL string) string { + return emailWrapper(fmt.Sprintf(` +

Reset your password

+

Hi %s,

+

We received a request to reset your password. Click the button below to set a new one:

+

+ Reset Password +

+

Or copy and paste this link into your browser:

+

%s

+

This link expires in 1 hour. If you didn't request this, please ignore this email.

`, name, resetURL, resetURL)) +} + +func renderTicketClosedEmail(name, ticketTitle, ticketURL string) string { + return emailWrapper(fmt.Sprintf(` +

Your ticket has been resolved

+

Hi %s,

+

Your ticket "%s" has been resolved by our team.

+

+ View Ticket +

+

If you believe the issue is not fully resolved, you can add a comment on the ticket page.

`, name, ticketTitle, ticketURL)) +} + +func renderWelcomeEmail(name, email, tempPassword, loginURL string) string { + return emailWrapper(fmt.Sprintf(` +

Welcome!

+

Hi %s,

+

An account has been created for you. Here are your login details:

+ + + +
Email:%s
Temporary Password:%s
+

+ Log In +

+

Please change your password after logging in.

`, name, email, tempPassword, loginURL)) +} diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go new file mode 100644 index 0000000..beb7281 --- /dev/null +++ b/internal/forgejo/client.go @@ -0,0 +1,116 @@ +package forgejo + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type Client struct { + baseURL string + apiToken string + httpClient *http.Client +} + +func NewClient(baseURL, apiToken string) *Client { + return &Client{ + baseURL: baseURL, + apiToken: apiToken, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +type CreateIssueRequest struct { + Title string `json:"title"` + Body string `json:"body"` +} + +type Issue struct { + Number int64 `json:"number"` + Title string `json:"title"` + State string `json:"state"` +} + +type CreateCommentRequest struct { + Body string `json:"body"` +} + +type Comment struct { + ID int64 `json:"id"` + Body string `json:"body"` +} + +func (c *Client) CreateIssue(owner, repo string, req CreateIssueRequest) (*Issue, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", c.baseURL, owner, repo) + + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "token "+c.apiToken) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("forgejo API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) + } + + var issue Issue + if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { + return nil, err + } + + return &issue, nil +} + +func (c *Client) CreateComment(owner, repo string, issueNumber int64, req CreateCommentRequest) (*Comment, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.baseURL, owner, repo, issueNumber) + + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "token "+c.apiToken) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("forgejo API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) + } + + var comment Comment + if err := json.NewDecoder(resp.Body).Decode(&comment); err != nil { + return nil, err + } + + return &comment, nil +} diff --git a/internal/forgejo/client_test.go b/internal/forgejo/client_test.go new file mode 100644 index 0000000..8c67c4e --- /dev/null +++ b/internal/forgejo/client_test.go @@ -0,0 +1,167 @@ +package forgejo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCreateIssue_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and path + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + expectedPath := "/api/v1/repos/testowner/testrepo/issues" + if r.URL.Path != expectedPath { + t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path) + } + + // Verify auth header + authHeader := r.Header.Get("Authorization") + if authHeader != "token test-token" { + t.Errorf("expected Authorization header %q, got %q", "token test-token", authHeader) + } + + // Verify content type + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("expected Content-Type application/json, got %s", ct) + } + + // Decode request body + var req CreateIssueRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + if req.Title != "Test Issue" { + t.Errorf("expected title %q, got %q", "Test Issue", req.Title) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(Issue{ + Number: 7, + Title: "Test Issue", + State: "open", + }) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + issue, err := client.CreateIssue("testowner", "testrepo", CreateIssueRequest{ + Title: "Test Issue", + Body: "This is a test issue body.", + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if issue.Number != 7 { + t.Errorf("expected issue number 7, got %d", issue.Number) + } + if issue.Title != "Test Issue" { + t.Errorf("expected issue title %q, got %q", "Test Issue", issue.Title) + } + if issue.State != "open" { + t.Errorf("expected issue state %q, got %q", "open", issue.State) + } +} + +func TestCreateIssue_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal server error")) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + _, err := client.CreateIssue("testowner", "testrepo", CreateIssueRequest{ + Title: "Test Issue", + Body: "Body", + }) + if err == nil { + t.Fatal("expected error for 500 response, got nil") + } +} + +func TestCreateIssue_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("not found")) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + _, err := client.CreateIssue("testowner", "testrepo", CreateIssueRequest{ + Title: "Test Issue", + Body: "Body", + }) + if err == nil { + t.Fatal("expected error for 404 response, got nil") + } +} + +func TestCreateComment_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + expectedPath := "/api/v1/repos/owner/repo/issues/5/comments" + if r.URL.Path != expectedPath { + t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path) + } + + authHeader := r.Header.Get("Authorization") + if authHeader != "token my-api-token" { + t.Errorf("expected Authorization header %q, got %q", "token my-api-token", authHeader) + } + + var req CreateCommentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + if req.Body != "A comment" { + t.Errorf("expected body %q, got %q", "A comment", req.Body) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(Comment{ + ID: 99, + Body: "A comment", + }) + })) + defer server.Close() + + client := NewClient(server.URL, "my-api-token") + comment, err := client.CreateComment("owner", "repo", 5, CreateCommentRequest{ + Body: "A comment", + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if comment.ID != 99 { + t.Errorf("expected comment ID 99, got %d", comment.ID) + } + if comment.Body != "A comment" { + t.Errorf("expected comment body %q, got %q", "A comment", comment.Body) + } +} + +func TestCreateComment_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("error")) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + _, err := client.CreateComment("owner", "repo", 1, CreateCommentRequest{ + Body: "A comment", + }) + if err == nil { + t.Fatal("expected error for 500 response, got nil") + } +} diff --git a/internal/forgejo/webhook.go b/internal/forgejo/webhook.go new file mode 100644 index 0000000..12635a7 --- /dev/null +++ b/internal/forgejo/webhook.go @@ -0,0 +1,52 @@ +package forgejo + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type WebhookPayload struct { + Action string `json:"action"` + Issue WebhookIssue `json:"issue"` +} + +type WebhookIssue struct { + Number int64 `json:"number"` + Title string `json:"title"` + State string `json:"state"` +} + +func VerifyWebhookSignature(r *http.Request, secret string) ([]byte, error) { + signature := r.Header.Get("X-Forgejo-Signature") + if signature == "" { + return nil, fmt.Errorf("missing X-Forgejo-Signature header") + } + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + expectedMAC := hex.EncodeToString(mac.Sum(nil)) + + if !hmac.Equal([]byte(signature), []byte(expectedMAC)) { + return nil, fmt.Errorf("invalid signature") + } + + return body, nil +} + +func ParseWebhookPayload(data []byte) (*WebhookPayload, error) { + var payload WebhookPayload + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("parse webhook payload: %w", err) + } + return &payload, nil +} diff --git a/internal/forgejo/webhook_test.go b/internal/forgejo/webhook_test.go new file mode 100644 index 0000000..509e502 --- /dev/null +++ b/internal/forgejo/webhook_test.go @@ -0,0 +1,124 @@ +package forgejo + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "testing" +) + +func computeHMAC(body []byte, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +func TestVerifyWebhookSignature_Valid(t *testing.T) { + secret := "test-webhook-secret" + payload := []byte(`{"action":"opened","issue":{"number":1,"title":"Test","state":"open"}}`) + signature := computeHMAC(payload, secret) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) + req.Header.Set("X-Forgejo-Signature", signature) + + body, err := VerifyWebhookSignature(req, secret) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if !bytes.Equal(body, payload) { + t.Errorf("expected body %q, got %q", payload, body) + } +} + +func TestVerifyWebhookSignature_InvalidSignature(t *testing.T) { + secret := "test-webhook-secret" + payload := []byte(`{"action":"opened"}`) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) + req.Header.Set("X-Forgejo-Signature", "deadbeef00000000000000000000000000000000000000000000000000000000") + + _, err := VerifyWebhookSignature(req, secret) + if err == nil { + t.Fatal("expected error for invalid signature, got nil") + } +} + +func TestVerifyWebhookSignature_MissingHeader(t *testing.T) { + payload := []byte(`{"action":"opened"}`) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) + // No X-Forgejo-Signature header set + + _, err := VerifyWebhookSignature(req, "some-secret") + if err == nil { + t.Fatal("expected error for missing signature header, got nil") + } + + expected := "missing X-Forgejo-Signature header" + if err.Error() != expected { + t.Errorf("expected error message %q, got %q", expected, err.Error()) + } +} + +func TestVerifyWebhookSignature_WrongSecret(t *testing.T) { + payload := []byte(`{"action":"opened"}`) + signature := computeHMAC(payload, "correct-secret") + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(payload)) + req.Header.Set("X-Forgejo-Signature", signature) + + _, err := VerifyWebhookSignature(req, "wrong-secret") + if err == nil { + t.Fatal("expected error for wrong secret, got nil") + } +} + +func TestParseWebhookPayload_Valid(t *testing.T) { + data := []byte(`{"action":"opened","issue":{"number":42,"title":"Bug report","state":"open"}}`) + + payload, err := ParseWebhookPayload(data) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if payload.Action != "opened" { + t.Errorf("expected action %q, got %q", "opened", payload.Action) + } + if payload.Issue.Number != 42 { + t.Errorf("expected issue number 42, got %d", payload.Issue.Number) + } + if payload.Issue.Title != "Bug report" { + t.Errorf("expected issue title %q, got %q", "Bug report", payload.Issue.Title) + } + if payload.Issue.State != "open" { + t.Errorf("expected issue state %q, got %q", "open", payload.Issue.State) + } +} + +func TestParseWebhookPayload_InvalidJSON(t *testing.T) { + data := []byte(`{not valid json}`) + + _, err := ParseWebhookPayload(data) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestParseWebhookPayload_EmptyObject(t *testing.T) { + data := []byte(`{}`) + + payload, err := ParseWebhookPayload(data) + if err != nil { + t.Fatalf("expected no error for empty object, got: %v", err) + } + + if payload.Action != "" { + t.Errorf("expected empty action, got %q", payload.Action) + } + if payload.Issue.Number != 0 { + t.Errorf("expected issue number 0, got %d", payload.Issue.Number) + } +} diff --git a/internal/handlers/admin/auth.go b/internal/handlers/admin/auth.go new file mode 100644 index 0000000..0e22011 --- /dev/null +++ b/internal/handlers/admin/auth.go @@ -0,0 +1,71 @@ +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/dashboard.go b/internal/handlers/admin/dashboard.go new file mode 100644 index 0000000..643ddd6 --- /dev/null +++ b/internal/handlers/admin/dashboard.go @@ -0,0 +1,46 @@ +package admin + +import ( + "github.com/gin-gonic/gin" + "github.com/mattnite/forgejo-tickets/internal/models" + "github.com/rs/zerolog/log" +) + +type DashboardHandler struct { + deps Dependencies +} + +func (h *DashboardHandler) Index(c *gin.Context) { + var userCount int64 + if err := h.deps.DB.Model(&models.User{}).Count(&userCount).Error; err != nil { + log.Error().Err(err).Msg("count users error") + } + + var totalTickets int64 + if err := h.deps.DB.Model(&models.Ticket{}).Count(&totalTickets).Error; err != nil { + log.Error().Err(err).Msg("count tickets error") + } + + var openTickets int64 + if err := h.deps.DB.Model(&models.Ticket{}).Where("status = ?", models.TicketStatusOpen).Count(&openTickets).Error; err != nil { + log.Error().Err(err).Msg("count open tickets error") + } + + var inProgressTickets int64 + if err := h.deps.DB.Model(&models.Ticket{}).Where("status = ?", models.TicketStatusInProgress).Count(&inProgressTickets).Error; err != nil { + log.Error().Err(err).Msg("count in_progress tickets error") + } + + var closedTickets int64 + if err := h.deps.DB.Model(&models.Ticket{}).Where("status = ?", models.TicketStatusClosed).Count(&closedTickets).Error; err != nil { + log.Error().Err(err).Msg("count closed tickets error") + } + + h.deps.Renderer.Render(c.Writer, c.Request, "admin/dashboard", map[string]interface{}{ + "UserCount": userCount, + "TotalTickets": totalTickets, + "OpenTickets": openTickets, + "InProgressTickets": inProgressTickets, + "ClosedTickets": closedTickets, + }) +} diff --git a/internal/handlers/admin/repos.go b/internal/handlers/admin/repos.go new file mode 100644 index 0000000..6d3b614 --- /dev/null +++ b/internal/handlers/admin/repos.go @@ -0,0 +1,127 @@ +package admin + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/mattnite/forgejo-tickets/internal/models" + "github.com/rs/zerolog/log" +) + +type RepoHandler struct { + deps Dependencies +} + +func (h *RepoHandler) List(c *gin.Context) { + var repos []models.Repo + if err := h.deps.DB.Order("name ASC").Find(&repos).Error; err != nil { + log.Error().Err(err).Msg("list repos error") + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load repos") + return + } + + h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/list", map[string]interface{}{ + "Repos": repos, + "BaseURL": h.deps.Config.BaseURL, + }) +} + +func (h *RepoHandler) NewForm(c *gin.Context) { + h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/new", nil) +} + +func (h *RepoHandler) Create(c *gin.Context) { + name := strings.TrimSpace(c.PostForm("name")) + slug := strings.TrimSpace(c.PostForm("slug")) + forgejoOwner := strings.TrimSpace(c.PostForm("forgejo_owner")) + forgejoRepo := strings.TrimSpace(c.PostForm("forgejo_repo")) + webhookSecret := strings.TrimSpace(c.PostForm("webhook_secret")) + active := c.PostForm("active") == "on" + + if name == "" || slug == "" || forgejoOwner == "" || forgejoRepo == "" || webhookSecret == "" { + h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/new", map[string]interface{}{ + "Error": "All fields are required", + "Name": name, + "Slug": slug, + "ForgejoOwner": forgejoOwner, + "ForgejoRepo": forgejoRepo, + "WebhookSecret": webhookSecret, + }) + return + } + + repo := models.Repo{ + Name: name, + Slug: slug, + ForgejoOwner: forgejoOwner, + ForgejoRepo: forgejoRepo, + WebhookSecret: webhookSecret, + Active: active, + } + + if err := h.deps.DB.Create(&repo).Error; err != nil { + log.Error().Err(err).Msg("create repo error") + h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/new", map[string]interface{}{ + "Error": "Failed to create repo: " + err.Error(), + "Name": name, + "Slug": slug, + "ForgejoOwner": forgejoOwner, + "ForgejoRepo": forgejoRepo, + "WebhookSecret": webhookSecret, + }) + return + } + + c.Redirect(http.StatusSeeOther, "/repos/"+repo.ID.String()+"/edit") +} + +func (h *RepoHandler) EditForm(c *gin.Context) { + repoID, err := uuid.Parse(c.Param("id")) + if err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid repo ID") + return + } + + var repo models.Repo + if err := h.deps.DB.First(&repo, "id = ?", repoID).Error; err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Repo not found") + return + } + + h.deps.Renderer.Render(c.Writer, c.Request, "admin/repos/edit", map[string]interface{}{ + "Repo": repo, + "BaseURL": h.deps.Config.BaseURL, + }) +} + +func (h *RepoHandler) Update(c *gin.Context) { + repoID, err := uuid.Parse(c.Param("id")) + if err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid repo ID") + return + } + + name := strings.TrimSpace(c.PostForm("name")) + slug := strings.TrimSpace(c.PostForm("slug")) + forgejoOwner := strings.TrimSpace(c.PostForm("forgejo_owner")) + forgejoRepo := strings.TrimSpace(c.PostForm("forgejo_repo")) + webhookSecret := strings.TrimSpace(c.PostForm("webhook_secret")) + active := c.PostForm("active") == "on" + + if err := h.deps.DB.Model(&models.Repo{}).Where("id = ?", repoID).Updates(map[string]interface{}{ + "name": name, + "slug": slug, + "forgejo_owner": forgejoOwner, + "forgejo_repo": forgejoRepo, + "webhook_secret": webhookSecret, + "active": active, + }).Error; err != nil { + log.Error().Err(err).Msg("update repo error") + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to update repo") + return + } + + c.Redirect(http.StatusSeeOther, "/repos/"+repoID.String()+"/edit") +} diff --git a/internal/handlers/admin/routes.go b/internal/handlers/admin/routes.go new file mode 100644 index 0000000..8b96e59 --- /dev/null +++ b/internal/handlers/admin/routes.go @@ -0,0 +1,53 @@ +package admin + +import ( + "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/middleware" + "github.com/mattnite/forgejo-tickets/internal/templates" + "gorm.io/gorm" +) + +type Dependencies struct { + DB *gorm.DB + Renderer *templates.Renderer + Auth *auth.Service + EmailClient *email.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) + + dashboardHandler := &DashboardHandler{deps: deps} + r.GET("/", dashboardHandler.Index) + + userHandler := &UserHandler{deps: deps} + r.GET("/users", userHandler.List) + r.GET("/users/new", userHandler.NewForm) + r.GET("/users/:id", userHandler.Detail) + r.POST("/users", userHandler.Create) + + ticketHandler := &TicketHandler{deps: deps} + r.GET("/tickets", ticketHandler.List) + r.GET("/tickets/:id", ticketHandler.Detail) + r.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 +} diff --git a/internal/handlers/admin/tickets.go b/internal/handlers/admin/tickets.go new file mode 100644 index 0000000..c920cca --- /dev/null +++ b/internal/handlers/admin/tickets.go @@ -0,0 +1,104 @@ +package admin + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/mattnite/forgejo-tickets/internal/models" + "github.com/rs/zerolog/log" +) + +type TicketHandler struct { + deps Dependencies +} + +type ticketListRow struct { + models.Ticket + RepoName string + RepoSlug string + UserEmail string + UserName string +} + +func (h *TicketHandler) List(c *gin.Context) { + statusFilter := c.Query("status") + + var tickets []ticketListRow + query := h.deps.DB.Table("tickets"). + Select("tickets.*, repos.name as repo_name, repos.slug as repo_slug, users.email as user_email, users.name as user_name"). + Joins("JOIN repos ON repos.id = tickets.repo_id"). + Joins("JOIN users ON users.id = tickets.user_id") + + if statusFilter != "" { + query = query.Where("tickets.status = ?", statusFilter) + } + + if err := query.Order("tickets.created_at DESC").Limit(100).Scan(&tickets).Error; err != nil { + log.Error().Err(err).Msg("list tickets error") + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load tickets") + return + } + + h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/list", map[string]interface{}{ + "Tickets": tickets, + "StatusFilter": statusFilter, + }) +} + +func (h *TicketHandler) Detail(c *gin.Context) { + ticketID, err := uuid.Parse(c.Param("id")) + if err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID") + return + } + + var ticket models.Ticket + if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Ticket not found") + return + } + + var user models.User + h.deps.DB.First(&user, "id = ?", ticket.UserID) + + var repo models.Repo + h.deps.DB.First(&repo, "id = ?", ticket.RepoID) + + var comments []struct { + models.TicketComment + UserName string + UserEmail string + } + h.deps.DB.Table("ticket_comments"). + Select("ticket_comments.*, users.name as user_name, users.email as user_email"). + Joins("JOIN users ON users.id = ticket_comments.user_id"). + Where("ticket_comments.ticket_id = ?", ticket.ID). + Order("ticket_comments.created_at ASC"). + Scan(&comments) + + h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/detail", map[string]interface{}{ + "Ticket": ticket, + "User": user, + "Repo": repo, + "Comments": comments, + }) +} + +func (h *TicketHandler) UpdateStatus(c *gin.Context) { + ticketID, err := uuid.Parse(c.Param("id")) + if err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID") + return + } + + status := models.TicketStatus(c.PostForm("status")) + + if err := h.deps.DB.Model(&models.Ticket{}).Where("id = ?", ticketID).Update("status", status).Error; err != nil { + log.Error().Err(err).Msg("update ticket status error") + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to update status") + return + } + + c.Redirect(http.StatusSeeOther, "/tickets/"+ticketID.String()) +} diff --git a/internal/handlers/admin/users.go b/internal/handlers/admin/users.go new file mode 100644 index 0000000..e3a134b --- /dev/null +++ b/internal/handlers/admin/users.go @@ -0,0 +1,98 @@ +package admin + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/mattnite/forgejo-tickets/internal/models" + "github.com/rs/zerolog/log" +) + +type UserHandler struct { + deps Dependencies +} + +func (h *UserHandler) List(c *gin.Context) { + var users []models.User + if err := h.deps.DB.Order("created_at DESC").Limit(100).Find(&users).Error; err != nil { + log.Error().Err(err).Msg("list users error") + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load users") + return + } + + h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/list", map[string]interface{}{ + "Users": users, + }) +} + +func (h *UserHandler) Detail(c *gin.Context) { + userID, err := uuid.Parse(c.Param("id")) + if err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid user ID") + return + } + + var user models.User + if err := h.deps.DB.First(&user, "id = ?", userID).Error; err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "User not found") + return + } + + var tickets []models.Ticket + h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").Limit(50).Find(&tickets) + + h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/detail", map[string]interface{}{ + "User": user, + "Tickets": tickets, + }) +} + +func (h *UserHandler) NewForm(c *gin.Context) { + h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/new", nil) +} + +func (h *UserHandler) Create(c *gin.Context) { + name := strings.TrimSpace(c.PostForm("name")) + email := strings.TrimSpace(c.PostForm("email")) + + if name == "" || email == "" { + h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/new", map[string]interface{}{ + "Error": "Name and email are required", + "Name": name, + "Email": email, + }) + return + } + + tempPassBytes := make([]byte, 12) + rand.Read(tempPassBytes) + tempPassword := hex.EncodeToString(tempPassBytes)[:16] + + user, err := h.deps.Auth.CreateUserWithPassword(c.Request.Context(), email, tempPassword, name, true) + if err != nil { + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") { + h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/new", map[string]interface{}{ + "Error": "A user with this email already exists", + "Name": name, + "Email": email, + }) + } else { + h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/new", map[string]interface{}{ + "Error": "Failed to create user: " + err.Error(), + "Name": name, + "Email": email, + }) + } + return + } + + if err := h.deps.EmailClient.SendWelcomeEmail(email, name, tempPassword); err != nil { + log.Error().Err(err).Msg("send welcome email error") + } + + c.Redirect(http.StatusSeeOther, "/users/"+user.ID.String()) +} diff --git a/internal/handlers/public/auth.go b/internal/handlers/public/auth.go new file mode 100644 index 0000000..052f375 --- /dev/null +++ b/internal/handlers/public/auth.go @@ -0,0 +1,202 @@ +package public + +import ( + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + "github.com/mattnite/forgejo-tickets/internal/auth" + "github.com/mattnite/forgejo-tickets/internal/models" + "github.com/rs/zerolog/log" +) + +type AuthHandler struct { + deps Dependencies +} + +func (h *AuthHandler) LoginForm(c *gin.Context) { + if auth.CurrentUser(c) != nil { + c.Redirect(http.StatusSeeOther, "/tickets") + return + } + h.deps.Renderer.Render(c.Writer, c.Request, "login", nil) +} + +func (h *AuthHandler) Login(c *gin.Context) { + email := strings.TrimSpace(c.PostForm("email")) + password := c.PostForm("password") + + user, err := h.deps.Auth.Login(c.Request.Context(), email, password) + if err != nil { + h.deps.Renderer.Render(c.Writer, c.Request, "login", map[string]interface{}{ + "Error": err.Error(), + "Email": email, + }) + return + } + + if err := h.deps.Auth.CreateSession(c.Request, c.Writer, user.ID); err != nil { + log.Error().Err(err).Msg("create session error") + h.deps.Renderer.Render(c.Writer, c.Request, "login", map[string]interface{}{ + "Error": "An unexpected error occurred", + "Email": email, + }) + return + } + + c.Redirect(http.StatusSeeOther, "/tickets") +} + +func (h *AuthHandler) RegisterForm(c *gin.Context) { + if auth.CurrentUser(c) != nil { + c.Redirect(http.StatusSeeOther, "/tickets") + return + } + h.deps.Renderer.Render(c.Writer, c.Request, "register", nil) +} + +func (h *AuthHandler) Register(c *gin.Context) { + name := strings.TrimSpace(c.PostForm("name")) + email := strings.TrimSpace(c.PostForm("email")) + password := c.PostForm("password") + confirmPassword := c.PostForm("confirm_password") + + data := map[string]interface{}{ + "Name": name, + "Email": email, + } + + if password != confirmPassword { + data["Error"] = "Passwords do not match" + h.deps.Renderer.Render(c.Writer, c.Request, "register", data) + return + } + + if len(password) < 8 { + data["Error"] = "Password must be at least 8 characters" + h.deps.Renderer.Render(c.Writer, c.Request, "register", data) + return + } + + user, err := h.deps.Auth.Register(c.Request.Context(), email, password, name) + if err != nil { + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") { + data["Error"] = "An account with this email already exists" + } else { + data["Error"] = "Registration failed. Please try again." + } + h.deps.Renderer.Render(c.Writer, c.Request, "register", data) + return + } + + token, err := h.deps.Auth.GenerateVerificationToken(c.Request.Context(), user.ID) + if err != nil { + log.Error().Err(err).Msg("generate verification token error") + } else { + if err := h.deps.EmailClient.SendVerificationEmail(user.Email, user.Name, token); err != nil { + log.Error().Err(err).Msg("send verification email error") + } + } + + redirectURL := "/login?" + url.Values{ + "flash": {"Please check your email to verify your account"}, + "flash_type": {"success"}, + }.Encode() + c.Redirect(http.StatusSeeOther, redirectURL) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + if err := h.deps.Auth.DestroySession(c.Request, c.Writer); err != nil { + log.Error().Err(err).Msg("destroy session error") + } + c.Redirect(http.StatusSeeOther, "/") +} + +func (h *AuthHandler) VerifyEmail(c *gin.Context) { + token := c.Query("token") + if token == "" { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Missing verification token") + return + } + + _, err := h.deps.Auth.VerifyEmailToken(c.Request.Context(), token) + if err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid or expired verification token") + return + } + + redirectURL := "/login?" + url.Values{ + "flash": {"Email verified successfully. You can now log in."}, + "flash_type": {"success"}, + }.Encode() + c.Redirect(http.StatusSeeOther, redirectURL) +} + +func (h *AuthHandler) ForgotPasswordForm(c *gin.Context) { + h.deps.Renderer.Render(c.Writer, c.Request, "forgot-password", nil) +} + +func (h *AuthHandler) ForgotPassword(c *gin.Context) { + email := strings.TrimSpace(c.PostForm("email")) + + var user models.User + if err := h.deps.DB.Where("email = ?", email).First(&user).Error; err == nil { + token, err := h.deps.Auth.GeneratePasswordResetToken(c.Request.Context(), user.ID) + if err != nil { + log.Error().Err(err).Msg("generate reset token error") + } else { + if err := h.deps.EmailClient.SendPasswordResetEmail(user.Email, user.Name, token); err != nil { + log.Error().Err(err).Msg("send reset email error") + } + } + } + + h.deps.Renderer.Render(c.Writer, c.Request, "forgot-password", map[string]interface{}{ + "Success": "If an account exists with that email, we've sent a password reset link.", + }) +} + +func (h *AuthHandler) ResetPasswordForm(c *gin.Context) { + token := c.Query("token") + h.deps.Renderer.Render(c.Writer, c.Request, "reset-password", map[string]interface{}{ + "Token": token, + }) +} + +func (h *AuthHandler) ResetPassword(c *gin.Context) { + token := c.PostForm("token") + password := c.PostForm("password") + confirmPassword := c.PostForm("confirm_password") + + if password != confirmPassword { + h.deps.Renderer.Render(c.Writer, c.Request, "reset-password", map[string]interface{}{ + "Token": token, + "Error": "Passwords do not match", + }) + return + } + + if len(password) < 8 { + h.deps.Renderer.Render(c.Writer, c.Request, "reset-password", map[string]interface{}{ + "Token": token, + "Error": "Password must be at least 8 characters", + }) + return + } + + _, err := h.deps.Auth.RedeemPasswordResetToken(c.Request.Context(), token, password) + if err != nil { + h.deps.Renderer.Render(c.Writer, c.Request, "reset-password", map[string]interface{}{ + "Token": token, + "Error": "Invalid or expired reset token", + }) + return + } + + redirectURL := "/login?" + url.Values{ + "flash": {"Password reset successfully. You can now log in."}, + "flash_type": {"success"}, + }.Encode() + c.Redirect(http.StatusSeeOther, redirectURL) +} diff --git a/internal/handlers/public/home.go b/internal/handlers/public/home.go new file mode 100644 index 0000000..8fc7395 --- /dev/null +++ b/internal/handlers/public/home.go @@ -0,0 +1,13 @@ +package public + +import ( + "github.com/gin-gonic/gin" +) + +type HomeHandler struct { + deps Dependencies +} + +func (h *HomeHandler) Index(c *gin.Context) { + h.deps.Renderer.Render(c.Writer, c.Request, "home", nil) +} diff --git a/internal/handlers/public/oauth.go b/internal/handlers/public/oauth.go new file mode 100644 index 0000000..acc0186 --- /dev/null +++ b/internal/handlers/public/oauth.go @@ -0,0 +1,206 @@ +package public + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mattnite/forgejo-tickets/internal/auth" + "github.com/rs/zerolog/log" + "golang.org/x/oauth2" +) + +type OAuthHandler struct { + deps Dependencies +} + +func (h *OAuthHandler) getProvider(providerName string) *auth.OAuthProvider { + cfg := h.deps.Config + baseURL := cfg.BaseURL + + switch providerName { + case "google": + if cfg.GoogleClientID == "" { + return nil + } + return auth.NewGoogleProvider(cfg.GoogleClientID, cfg.GoogleClientSecret, baseURL+"/auth/google/callback") + case "microsoft": + if cfg.MicrosoftClientID == "" { + return nil + } + return auth.NewMicrosoftProvider(cfg.MicrosoftClientID, cfg.MicrosoftClientSecret, cfg.MicrosoftTenantID, baseURL+"/auth/microsoft/callback") + default: + return nil + } +} + +func (h *OAuthHandler) Login(c *gin.Context) { + providerName := c.Param("provider") + + // Handle Apple separately + if providerName == "apple" { + h.appleLogin(c) + return + } + + provider := h.getProvider(providerName) + if provider == nil { + c.String(http.StatusBadRequest, "Unknown provider") + return + } + + state := generateState() + session, _ := h.deps.SessionStore.Get(c.Request, "oauth_state") + session.Values["state"] = state + session.Values["user_id"] = "00000000-0000-0000-0000-000000000000" // placeholder for save + if err := session.Save(c.Request, c.Writer); err != nil { + log.Error().Err(err).Msg("save oauth state error") + } + + url := provider.Config.AuthCodeURL(state, oauth2.AccessTypeOffline) + c.Redirect(http.StatusTemporaryRedirect, url) +} + +func (h *OAuthHandler) Callback(c *gin.Context) { + providerName := c.Param("provider") + provider := h.getProvider(providerName) + if provider == nil { + c.String(http.StatusBadRequest, "Unknown provider") + return + } + + // Verify state + session, _ := h.deps.SessionStore.Get(c.Request, "oauth_state") + expectedState, _ := session.Values["state"].(string) + if c.Query("state") != expectedState { + c.String(http.StatusBadRequest, "Invalid state parameter") + return + } + + code := c.Query("code") + token, err := provider.Config.Exchange(c.Request.Context(), code) + if err != nil { + log.Error().Err(err).Msg("oauth exchange error") + c.String(http.StatusInternalServerError, "Authentication failed") + return + } + + info, err := provider.UserInfo(c.Request.Context(), token) + if err != nil { + log.Error().Err(err).Msg("oauth userinfo error") + c.String(http.StatusInternalServerError, "Failed to get user info") + return + } + + user, err := h.deps.Auth.FindOrCreateOAuthUser(c.Request.Context(), provider.Name, info) + if err != nil { + log.Error().Err(err).Msg("find or create oauth user error") + c.String(http.StatusInternalServerError, "Authentication failed") + return + } + + if err := h.deps.Auth.CreateSession(c.Request, c.Writer, user.ID); err != nil { + log.Error().Err(err).Msg("create session error") + c.String(http.StatusInternalServerError, "Authentication failed") + return + } + + c.Redirect(http.StatusSeeOther, "/tickets") +} + +func (h *OAuthHandler) appleLogin(c *gin.Context) { + cfg := h.deps.Config + if cfg.AppleClientID == "" { + c.String(http.StatusBadRequest, "Apple Sign In not configured") + return + } + + appleProvider, err := auth.NewAppleProvider( + cfg.AppleClientID, cfg.AppleTeamID, cfg.AppleKeyID, cfg.AppleKeyPath, + cfg.BaseURL+"/auth/apple/callback", + ) + if err != nil { + log.Error().Err(err).Msg("create apple provider error") + c.String(http.StatusInternalServerError, "Apple Sign In not available") + return + } + + state := generateState() + session, _ := h.deps.SessionStore.Get(c.Request, "oauth_state") + session.Values["state"] = state + session.Values["user_id"] = "00000000-0000-0000-0000-000000000000" + if err := session.Save(c.Request, c.Writer); err != nil { + log.Error().Err(err).Msg("save oauth state error") + } + + url := appleProvider.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, auth.AppleAuthCodeOption()) + c.Redirect(http.StatusTemporaryRedirect, url) +} + +func (h *OAuthHandler) AppleCallback(c *gin.Context) { + cfg := h.deps.Config + appleProvider, err := auth.NewAppleProvider( + cfg.AppleClientID, cfg.AppleTeamID, cfg.AppleKeyID, cfg.AppleKeyPath, + cfg.BaseURL+"/auth/apple/callback", + ) + if err != nil { + log.Error().Err(err).Msg("create apple provider error") + c.String(http.StatusInternalServerError, "Apple Sign In not available") + return + } + + // Apple uses form_post + code := c.PostForm("code") + state := c.PostForm("state") + + session, _ := h.deps.SessionStore.Get(c.Request, "oauth_state") + expectedState, _ := session.Values["state"].(string) + if state != expectedState { + c.String(http.StatusBadRequest, "Invalid state parameter") + return + } + + token, err := appleProvider.ExchangeCode(c.Request.Context(), code) + if err != nil { + log.Error().Err(err).Msg("apple exchange error") + c.String(http.StatusInternalServerError, "Authentication failed") + return + } + + info, err := appleProvider.UserInfo(c.Request.Context(), token) + if err != nil { + log.Error().Err(err).Msg("apple userinfo error") + c.String(http.StatusInternalServerError, "Failed to get user info") + return + } + + // Apple may send user data in the form + if userData := c.PostForm("user"); userData != "" { + appleUser, err := auth.ParseAppleUserData(userData) + if err == nil && appleUser != nil && appleUser.Name != nil { + info.Name = appleUser.Name.FirstName + " " + appleUser.Name.LastName + } + } + + user, err := h.deps.Auth.FindOrCreateOAuthUser(c.Request.Context(), "apple", info) + if err != nil { + log.Error().Err(err).Msg("find or create apple user error") + c.String(http.StatusInternalServerError, "Authentication failed") + return + } + + if err := h.deps.Auth.CreateSession(c.Request, c.Writer, user.ID); err != nil { + log.Error().Err(err).Msg("create session error") + c.String(http.StatusInternalServerError, "Authentication failed") + return + } + + c.Redirect(http.StatusSeeOther, "/tickets") +} + +func generateState() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/handlers/public/routes.go b/internal/handlers/public/routes.go new file mode 100644 index 0000000..eca70f6 --- /dev/null +++ b/internal/handlers/public/routes.go @@ -0,0 +1,84 @@ +package public + +import ( + "net/http" + "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" +) + +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) + r.Use(deps.Auth.SessionMiddleware) + + csrfSecret := []byte(deps.Config.SessionSecret) + isSecure := strings.HasPrefix(deps.Config.BaseURL, "https") + csrfMiddleware := middleware.CSRF(csrfSecret, isSecure) + + r.GET("/health", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + r.Static("/static", "web/static") + + webhookHandler := &WebhookHandler{deps: deps} + r.POST("/webhooks/forgejo/:repoSlug", webhookHandler.HandleForgejoWebhook) + + csrf := r.Group("/") + csrf.Use(csrfMiddleware) + { + homeHandler := &HomeHandler{deps: deps} + csrf.GET("/", homeHandler.Index) + + authHandler := &AuthHandler{deps: deps} + csrf.GET("/login", authHandler.LoginForm) + csrf.POST("/login", authHandler.Login) + csrf.GET("/register", authHandler.RegisterForm) + csrf.POST("/register", authHandler.Register) + csrf.POST("/logout", authHandler.Logout) + csrf.GET("/verify-email", authHandler.VerifyEmail) + csrf.GET("/forgot-password", authHandler.ForgotPasswordForm) + csrf.POST("/forgot-password", authHandler.ForgotPassword) + csrf.GET("/reset-password", authHandler.ResetPasswordForm) + csrf.POST("/reset-password", authHandler.ResetPassword) + + oauthHandler := &OAuthHandler{deps: deps} + csrf.GET("/auth/:provider/login", oauthHandler.Login) + csrf.GET("/auth/:provider/callback", oauthHandler.Callback) + csrf.POST("/auth/apple/callback", oauthHandler.AppleCallback) + + authenticated := csrf.Group("/") + authenticated.Use(auth.RequireAuth) + { + ticketHandler := &TicketHandler{deps: deps} + authenticated.GET("/tickets", ticketHandler.List) + authenticated.GET("/tickets/new", ticketHandler.NewForm) + authenticated.POST("/tickets", ticketHandler.Create) + authenticated.GET("/tickets/:id", ticketHandler.Detail) + authenticated.POST("/tickets/:id/comments", ticketHandler.AddComment) + } + } + + return r +} diff --git a/internal/handlers/public/tickets.go b/internal/handlers/public/tickets.go new file mode 100644 index 0000000..9644a69 --- /dev/null +++ b/internal/handlers/public/tickets.go @@ -0,0 +1,201 @@ +package public + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/mattnite/forgejo-tickets/internal/auth" + "github.com/mattnite/forgejo-tickets/internal/forgejo" + "github.com/mattnite/forgejo-tickets/internal/models" + "github.com/rs/zerolog/log" +) + +type TicketHandler struct { + deps Dependencies +} + +func (h *TicketHandler) List(c *gin.Context) { + user := auth.CurrentUser(c) + + var tickets []models.Ticket + if err := h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").Limit(50).Find(&tickets).Error; err != nil { + log.Error().Err(err).Msg("list tickets error") + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load tickets") + return + } + + h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{ + "Tickets": tickets, + }) +} + +func (h *TicketHandler) NewForm(c *gin.Context) { + var repos []models.Repo + if err := h.deps.DB.Where("active = ?", true).Order("name ASC").Find(&repos).Error; err != nil { + log.Error().Err(err).Msg("list repos error") + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load products") + return + } + + h.deps.Renderer.Render(c.Writer, c.Request, "tickets/new", map[string]interface{}{ + "Repos": repos, + }) +} + +func (h *TicketHandler) Create(c *gin.Context) { + user := auth.CurrentUser(c) + + repoID, err := uuid.Parse(c.PostForm("repo_id")) + if err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid product selection") + return + } + + title := c.PostForm("title") + description := c.PostForm("description") + + if title == "" || description == "" { + var repos []models.Repo + h.deps.DB.Where("active = ?", true).Order("name ASC").Find(&repos) + h.deps.Renderer.Render(c.Writer, c.Request, "tickets/new", map[string]interface{}{ + "Repos": repos, + "Error": "Title and description are required", + "Title": title, + "Description": description, + "RepoID": repoID.String(), + }) + return + } + + ticket := models.Ticket{ + UserID: user.ID, + RepoID: repoID, + Title: title, + Description: description, + } + + if err := h.deps.DB.Create(&ticket).Error; err != nil { + log.Error().Err(err).Msg("create ticket error") + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to create ticket") + return + } + + // Async Forgejo issue creation + var repo models.Repo + if err := h.deps.DB.First(&repo, "id = ?", repoID).Error; err == nil { + go func() { + issue, err := h.deps.ForgejoClient.CreateIssue(repo.ForgejoOwner, repo.ForgejoRepo, forgejo.CreateIssueRequest{ + Title: title, + Body: description + "\n\n---\n*Submitted by: " + user.Email + "*", + }) + if err != nil { + log.Error().Err(err).Msgf("forgejo create issue error for ticket %s", ticket.ID) + return + } + h.deps.DB.Model(&ticket).Update("forgejo_issue_number", issue.Number) + }() + } + + c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) +} + +func (h *TicketHandler) Detail(c *gin.Context) { + user := auth.CurrentUser(c) + + ticketID, err := uuid.Parse(c.Param("id")) + if err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID") + return + } + + var ticket models.Ticket + if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Ticket not found") + return + } + + if ticket.UserID != user.ID { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "Access denied") + return + } + + var repo models.Repo + h.deps.DB.First(&repo, "id = ?", ticket.RepoID) + + var comments []struct { + models.TicketComment + UserName string + UserEmail string + } + h.deps.DB.Table("ticket_comments"). + Select("ticket_comments.*, users.name as user_name, users.email as user_email"). + Joins("JOIN users ON users.id = ticket_comments.user_id"). + Where("ticket_comments.ticket_id = ?", ticket.ID). + Order("ticket_comments.created_at ASC"). + Scan(&comments) + + h.deps.Renderer.Render(c.Writer, c.Request, "tickets/detail", map[string]interface{}{ + "Ticket": ticket, + "Repo": repo, + "Comments": comments, + }) +} + +func (h *TicketHandler) AddComment(c *gin.Context) { + user := auth.CurrentUser(c) + + ticketID, err := uuid.Parse(c.Param("id")) + if err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID") + return + } + + var ticket models.Ticket + if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Ticket not found") + return + } + + if ticket.UserID != user.ID { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "Access denied") + return + } + + body := c.PostForm("body") + if body == "" { + c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) + return + } + + comment := models.TicketComment{ + TicketID: ticket.ID, + UserID: user.ID, + Body: body, + } + + if err := h.deps.DB.Create(&comment).Error; err != nil { + log.Error().Err(err).Msg("create comment error") + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to add comment") + return + } + + // Async sync to Forgejo + if ticket.ForgejoIssueNumber != nil { + var repo models.Repo + if err := h.deps.DB.First(&repo, "id = ?", ticket.RepoID).Error; err == nil { + go func() { + forgejoComment, err := h.deps.ForgejoClient.CreateComment(repo.ForgejoOwner, repo.ForgejoRepo, *ticket.ForgejoIssueNumber, forgejo.CreateCommentRequest{ + Body: body + "\n\n---\n*Comment by: " + user.Email + "*", + }) + if err != nil { + log.Error().Err(err).Msg("forgejo create comment error") + return + } + h.deps.DB.Model(&comment).Update("forgejo_comment_id", forgejoComment.ID) + }() + } + } + + c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) +} diff --git a/internal/handlers/public/webhook.go b/internal/handlers/public/webhook.go new file mode 100644 index 0000000..d04148e --- /dev/null +++ b/internal/handlers/public/webhook.go @@ -0,0 +1,70 @@ +package public + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mattnite/forgejo-tickets/internal/forgejo" + "github.com/mattnite/forgejo-tickets/internal/models" + "github.com/rs/zerolog/log" +) + +type WebhookHandler struct { + deps Dependencies +} + +func (h *WebhookHandler) HandleForgejoWebhook(c *gin.Context) { + repoSlug := c.Param("repoSlug") + + var repo models.Repo + if err := h.deps.DB.Where("slug = ?", repoSlug).First(&repo).Error; err != nil { + log.Error().Msgf("webhook: unknown repo slug %q", repoSlug) + c.String(http.StatusNotFound, "Unknown repo") + return + } + + body, err := forgejo.VerifyWebhookSignature(c.Request, repo.WebhookSecret) + if err != nil { + log.Error().Err(err).Msgf("webhook: signature verification failed for %q", repoSlug) + c.String(http.StatusUnauthorized, "Invalid signature") + return + } + + payload, err := forgejo.ParseWebhookPayload(body) + if err != nil { + log.Error().Err(err).Msg("webhook: parse error") + c.String(http.StatusBadRequest, "Invalid payload") + return + } + + if payload.Action != "closed" { + c.Status(http.StatusOK) + return + } + + var ticket models.Ticket + if err := h.deps.DB.Where("repo_id = ? AND forgejo_issue_number = ?", repo.ID, payload.Issue.Number).First(&ticket).Error; err != nil { + log.Info().Msgf("webhook: no ticket found for repo %s issue #%d", repoSlug, payload.Issue.Number) + c.Status(http.StatusOK) + return + } + + if err := h.deps.DB.Model(&ticket).Update("status", models.TicketStatusClosed).Error; err != nil { + log.Error().Err(err).Msg("webhook: update ticket status error") + c.String(http.StatusInternalServerError, "Internal error") + return + } + + var user models.User + if err := h.deps.DB.First(&user, "id = ?", ticket.UserID).Error; err == nil { + go func() { + if err := h.deps.EmailClient.SendTicketClosedNotification( + user.Email, user.Name, ticket.Title, ticket.ID.String(), + ); err != nil { + log.Error().Err(err).Msg("webhook: send notification error") + } + }() + } + + c.Status(http.StatusOK) +} diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go new file mode 100644 index 0000000..d722554 --- /dev/null +++ b/internal/middleware/csrf.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gorilla/csrf" +) + +func CSRF(secret []byte, secure bool) gin.HandlerFunc { + protect := csrf.Protect( + secret, + csrf.Secure(secure), + csrf.Path("/"), + csrf.SameSite(csrf.SameSiteLaxMode), + ) + + return func(c *gin.Context) { + // Wrap gin's handler chain as an http.Handler so gorilla/csrf can call it + protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Update gin's request in case csrf middleware modified it (added context values) + c.Request = r + c.Next() + })).ServeHTTP(c.Writer, c.Request) + // If csrf rejected the request, abort gin's chain + if c.Writer.Written() && c.Writer.Status() >= 400 { + c.Abort() + } + } +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..7fc3d03 --- /dev/null +++ b/internal/middleware/middleware.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + "runtime/debug" + "time" +) + +func RequestID(c *gin.Context) { + id := make([]byte, 8) + rand.Read(id) + reqID := hex.EncodeToString(id) + c.Writer.Header().Set("X-Request-ID", reqID) + c.Next() +} + +type responseWriter struct { + http.ResponseWriter + status int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.status = code + rw.ResponseWriter.WriteHeader(code) +} + +func Logging(c *gin.Context) { + start := time.Now() + c.Next() + log.Info().Msgf("%s %s %d %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(start)) +} + +func Recovery(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + log.Error().Msgf("panic: %v\n%s", err, debug.Stack()) + c.AbortWithStatus(http.StatusInternalServerError) + } + }() + c.Next() +} diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go new file mode 100644 index 0000000..359d616 --- /dev/null +++ b/internal/middleware/middleware_test.go @@ -0,0 +1,132 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func TestRequestID_SetsHeader(t *testing.T) { + r := gin.New() + r.Use(RequestID) + r.GET("/", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + + r.ServeHTTP(rr, req) + + reqID := rr.Header().Get("X-Request-ID") + if reqID == "" { + t.Fatal("expected X-Request-ID header to be set, but it was empty") + } + + // 8 random bytes = 16 hex characters + if len(reqID) != 16 { + t.Errorf("expected X-Request-ID length 16, got %d (%q)", len(reqID), reqID) + } +} + +func TestRequestID_UniquePerRequest(t *testing.T) { + r := gin.New() + r.Use(RequestID) + r.GET("/", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req1 := httptest.NewRequest(http.MethodGet, "/", nil) + rr1 := httptest.NewRecorder() + r.ServeHTTP(rr1, req1) + + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + + id1 := rr1.Header().Get("X-Request-ID") + id2 := rr2.Header().Get("X-Request-ID") + + if id1 == id2 { + t.Errorf("expected unique request IDs, but both were %q", id1) + } +} + +func TestLogging_DoesNotPanic(t *testing.T) { + r := gin.New() + r.Use(Logging) + r.GET("/test-path", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/test-path", nil) + rr := httptest.NewRecorder() + + // Should not panic + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } +} + +func TestLogging_RecordsStatus(t *testing.T) { + r := gin.New() + r.Use(Logging) + r.GET("/missing", func(c *gin.Context) { + c.Status(http.StatusNotFound) + }) + + req := httptest.NewRequest(http.MethodGet, "/missing", nil) + rr := httptest.NewRecorder() + + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d", rr.Code) + } +} + +func TestRecovery_CatchesPanics(t *testing.T) { + r := gin.New() + r.Use(Recovery) + r.GET("/", func(c *gin.Context) { + panic("something went wrong") + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + + // Should not propagate the panic + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", rr.Code) + } +} + +func TestRecovery_PassesThroughNormally(t *testing.T) { + r := gin.New() + r.Use(Recovery) + r.GET("/", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rr.Code) + } + if rr.Body.String() != "ok" { + t.Errorf("expected body %q, got %q", "ok", rr.Body.String()) + } +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..99a5cc6 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,127 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type TicketStatus string + +const ( + TicketStatusOpen TicketStatus = "open" + TicketStatusInProgress TicketStatus = "in_progress" + TicketStatusClosed TicketStatus = "closed" +) + +type TokenType string + +const ( + TokenTypeVerifyEmail TokenType = "verify_email" + TokenTypeResetPassword TokenType = "reset_password" +) + +type User struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + PasswordHash *string `json:"-"` + Name string `gorm:"not null" json:"name"` + EmailVerified bool `gorm:"not null;default:false" json:"email_verified"` + CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"` +} + +type OAuthAccount struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"` + Provider string `gorm:"not null" json:"provider"` + ProviderUserID string `gorm:"not null" json:"provider_user_id"` + Email string `gorm:"not null" json:"email"` + CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` +} + +func (OAuthAccount) TableName() string { return "oauth_accounts" } + +type Session struct { + Token string `gorm:"primaryKey" json:"token"` + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"` + Data []byte `gorm:"not null" json:"data"` + ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"` +} + +type Repo struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + Slug string `gorm:"uniqueIndex;not null" json:"slug"` + ForgejoOwner string `gorm:"not null" json:"forgejo_owner"` + ForgejoRepo string `gorm:"not null" json:"forgejo_repo"` + WebhookSecret string `gorm:"not null" json:"webhook_secret"` + Active bool `gorm:"not null;default:true" json:"active"` + CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` +} + +type Ticket struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"` + RepoID uuid.UUID `gorm:"type:uuid;not null;index" json:"repo_id"` + Repo Repo `gorm:"foreignKey:RepoID;constraint:OnDelete:CASCADE" json:"-"` + Title string `gorm:"not null" json:"title"` + Description string `gorm:"not null" json:"description"` + Status TicketStatus `gorm:"type:ticket_status;not null;default:'open';index" json:"status"` + ForgejoIssueNumber *int64 `json:"forgejo_issue_number"` + CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"` +} + +type TicketComment struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + TicketID uuid.UUID `gorm:"type:uuid;not null;index" json:"ticket_id"` + Ticket Ticket `gorm:"foreignKey:TicketID;constraint:OnDelete:CASCADE" json:"-"` + UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"` + Body string `gorm:"not null" json:"body"` + ForgejoCommentID *int64 `json:"forgejo_comment_id"` + CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` +} + +type EmailToken struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"` + TokenHash string `gorm:"uniqueIndex;not null" json:"token_hash"` + TokenType TokenType `gorm:"type:token_type;not null" json:"token_type"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at"` + UsedAt *time.Time `json:"used_at"` +} + +// AutoMigrate runs GORM auto-migration for all models. +// Note: enum types and partial indexes must be created via SQL migrations. +func AutoMigrate(db *gorm.DB) error { + // Create enum types if they don't exist + db.Exec("DO $$ BEGIN CREATE TYPE ticket_status AS ENUM ('open', 'in_progress', 'closed'); EXCEPTION WHEN duplicate_object THEN null; END $$;") + db.Exec("DO $$ BEGIN CREATE TYPE token_type AS ENUM ('verify_email', 'reset_password'); EXCEPTION WHEN duplicate_object THEN null; END $$;") + + if err := db.AutoMigrate( + &User{}, + &OAuthAccount{}, + &Session{}, + &Repo{}, + &Ticket{}, + &TicketComment{}, + &EmailToken{}, + ); err != nil { + return err + } + + // Create unique composite index for oauth_accounts + db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_provider_user ON oauth_accounts(provider, provider_user_id)") + + // Create partial unique index for ticket forgejo issue lookup + db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_repo_forgejo_issue ON tickets(repo_id, forgejo_issue_number) WHERE forgejo_issue_number IS NOT NULL") + + return nil +} diff --git a/internal/models/models_test.go b/internal/models/models_test.go new file mode 100644 index 0000000..a6e6840 --- /dev/null +++ b/internal/models/models_test.go @@ -0,0 +1,69 @@ +package models + +import ( + "testing" +) + +func TestTicketStatusConstants(t *testing.T) { + tests := []struct { + status TicketStatus + expected string + }{ + {TicketStatusOpen, "open"}, + {TicketStatusInProgress, "in_progress"}, + {TicketStatusClosed, "closed"}, + } + + for _, tt := range tests { + if string(tt.status) != tt.expected { + t.Errorf("expected TicketStatus %q, got %q", tt.expected, string(tt.status)) + } + } +} + +func TestTokenTypeConstants(t *testing.T) { + tests := []struct { + tokenType TokenType + expected string + }{ + {TokenTypeVerifyEmail, "verify_email"}, + {TokenTypeResetPassword, "reset_password"}, + } + + for _, tt := range tests { + if string(tt.tokenType) != tt.expected { + t.Errorf("expected TokenType %q, got %q", tt.expected, string(tt.tokenType)) + } + } +} + +func TestTicketStatusValues_AreDistinct(t *testing.T) { + statuses := []TicketStatus{ + TicketStatusOpen, + TicketStatusInProgress, + TicketStatusClosed, + } + + seen := make(map[TicketStatus]bool) + for _, s := range statuses { + if seen[s] { + t.Errorf("duplicate TicketStatus value: %q", s) + } + seen[s] = true + } +} + +func TestTokenTypeValues_AreDistinct(t *testing.T) { + types := []TokenType{ + TokenTypeVerifyEmail, + TokenTypeResetPassword, + } + + seen := make(map[TokenType]bool) + for _, tt := range types { + if seen[tt] { + t.Errorf("duplicate TokenType value: %q", tt) + } + seen[tt] = true + } +} diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go new file mode 100644 index 0000000..b23e9ed --- /dev/null +++ b/internal/templates/funcs.go @@ -0,0 +1,48 @@ +package templates + +import ( + "fmt" + "html/template" + "strings" + "time" +) + +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "formatDate": func(t time.Time) string { + return t.Format("Jan 2, 2006") + }, + "formatDateTime": func(t time.Time) string { + return t.Format("Jan 2, 2006 3:04 PM") + }, + "statusBadge": func(status string) template.HTML { + var class string + switch status { + case "open": + class = "bg-yellow-100 text-yellow-800" + case "in_progress": + class = "bg-blue-100 text-blue-800" + case "closed": + class = "bg-green-100 text-green-800" + default: + class = "bg-gray-100 text-gray-800" + } + label := strings.ReplaceAll(status, "_", " ") + return template.HTML(fmt.Sprintf(`%s`, class, label)) + }, + "truncate": func(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." + }, + "dict": func(values ...interface{}) map[string]interface{} { + dict := make(map[string]interface{}) + for i := 0; i < len(values)-1; i += 2 { + key, _ := values[i].(string) + dict[key] = values[i+1] + } + return dict + }, + } +} diff --git a/internal/templates/render.go b/internal/templates/render.go new file mode 100644 index 0000000..f9872c7 --- /dev/null +++ b/internal/templates/render.go @@ -0,0 +1,140 @@ +package templates + +import ( + "bytes" + "fmt" + "html/template" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gorilla/csrf" + "github.com/mattnite/forgejo-tickets/internal/auth" + "github.com/mattnite/forgejo-tickets/internal/models" +) + +type Renderer struct { + templates map[string]*template.Template +} + +type PageData struct { + User *models.User + CSRFToken string + Flash *Flash + Data interface{} +} + +type Flash struct { + Type string // "success", "error", "info" + Message string +} + +func NewRenderer() (*Renderer, error) { + r := &Renderer{ + templates: make(map[string]*template.Template), + } + + if err := r.parseTemplates(); err != nil { + return nil, err + } + + return r, nil +} + +func (r *Renderer) parseTemplates() error { + templateDir := "web/templates" + + partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.html")) + if err != nil { + return fmt.Errorf("glob partials: %w", err) + } + + err = filepath.WalkDir(filepath.Join(templateDir, "pages"), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".html") { + return nil + } + + layout := filepath.Join(templateDir, "layouts", "base.html") + if strings.Contains(path, "admin") { + layout = filepath.Join(templateDir, "layouts", "admin.html") + } + + if _, err := os.Stat(layout); os.IsNotExist(err) { + return nil + } + + files := []string{layout} + files = append(files, partials...) + files = append(files, path) + + name := strings.TrimPrefix(path, filepath.Join(templateDir, "pages")+"/") + name = strings.TrimSuffix(name, ".html") + + tmpl, err := template.New(filepath.Base(layout)).Funcs(templateFuncs()).ParseFiles(files...) + if err != nil { + return fmt.Errorf("parse template %s: %w", name, err) + } + + r.templates[name] = tmpl + return nil + }) + if err != nil { + return err + } + + return nil +} + +func (r *Renderer) Render(w http.ResponseWriter, req *http.Request, name string, data interface{}) { + tmpl, ok := r.templates[name] + if !ok { + http.Error(w, fmt.Sprintf("template %q not found", name), http.StatusInternalServerError) + return + } + + pd := PageData{ + User: auth.CurrentUserFromRequest(req), + CSRFToken: csrf.Token(req), + Data: data, + } + + if msg := req.URL.Query().Get("flash"); msg != "" { + flashType := req.URL.Query().Get("flash_type") + if flashType == "" { + flashType = "info" + } + pd.Flash = &Flash{Type: flashType, Message: msg} + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, pd); err != nil { + http.Error(w, fmt.Sprintf("render template: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + buf.WriteTo(w) +} + +func (r *Renderer) RenderError(w http.ResponseWriter, req *http.Request, status int, message string) { + w.WriteHeader(status) + if tmpl, ok := r.templates["error"]; ok { + pd := PageData{ + User: auth.CurrentUserFromRequest(req), + CSRFToken: csrf.Token(req), + Data: map[string]interface{}{"Status": status, "Message": message}, + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, pd); err == nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + buf.WriteTo(w) + return + } + } + http.Error(w, message, status) +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..485b7bf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "forgejo-tickets", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "tailwindcss": "^4.1.18" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8601870 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "tailwindcss": "^4.1.18" + } +} diff --git a/web/static/css/input.css b/web/static/css/input.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/web/static/css/input.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/web/templates/layouts/admin.html b/web/templates/layouts/admin.html new file mode 100644 index 0000000..041e073 --- /dev/null +++ b/web/templates/layouts/admin.html @@ -0,0 +1,31 @@ + + + + + + {{block "title" .}}Admin{{end}} + + + +
+ +
+ {{block "content" .}}{{end}} +
+
+ + diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html new file mode 100644 index 0000000..1c18e90 --- /dev/null +++ b/web/templates/layouts/base.html @@ -0,0 +1,18 @@ + + + + + + {{block "title" .}}Support{{end}} + + + +
+ {{template "nav" .}} + {{template "flash" .}} +
+ {{block "content" .}}{{end}} +
+
+ + diff --git a/web/templates/pages/admin/dashboard.html b/web/templates/pages/admin/dashboard.html new file mode 100644 index 0000000..010b1a0 --- /dev/null +++ b/web/templates/pages/admin/dashboard.html @@ -0,0 +1,26 @@ +{{define "title"}}Admin Dashboard{{end}} + +{{define "content"}} +

Dashboard

+ +{{with .Data}} +
+
+
Total Users
+
{{.UserCount}}
+
+
+
Open Tickets
+
{{.OpenTickets}}
+
+
+
In Progress
+
{{.InProgressTickets}}
+
+
+
Closed Tickets
+
{{.ClosedTickets}}
+
+
+{{end}} +{{end}} diff --git a/web/templates/pages/admin/repos/edit.html b/web/templates/pages/admin/repos/edit.html new file mode 100644 index 0000000..5061e22 --- /dev/null +++ b/web/templates/pages/admin/repos/edit.html @@ -0,0 +1,61 @@ +{{define "title"}}Edit Repo{{end}} + +{{define "content"}} +{{with .Data}} +
+ + +

Edit Repo

+ +
+

+ Webhook URL: + {{.BaseURL}}/webhooks/forgejo/{{.Repo.Slug}} +

+

Configure this URL in Forgejo's webhook settings for this repo.

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+{{end}} +{{end}} diff --git a/web/templates/pages/admin/repos/list.html b/web/templates/pages/admin/repos/list.html new file mode 100644 index 0000000..6a8b5e5 --- /dev/null +++ b/web/templates/pages/admin/repos/list.html @@ -0,0 +1,47 @@ +{{define "title"}}Repos{{end}} + +{{define "content"}} +
+

Repos

+ Add Repo +
+ +{{with .Data}} +{{if .Repos}} +
+ + + + + + + + + + + + {{range .Repos}} + + + + + + + + {{end}} + +
NameForgejoWebhook URLActive
{{.Name}}{{.ForgejoOwner}}/{{.ForgejoRepo}}/webhooks/forgejo/{{.Slug}} + {{if .Active}} + Active + {{else}} + Inactive + {{end}} + + Edit +
+
+{{else}} +

No repos configured. Add one to get started.

+{{end}} +{{end}} +{{end}} diff --git a/web/templates/pages/admin/repos/new.html b/web/templates/pages/admin/repos/new.html new file mode 100644 index 0000000..041a3f1 --- /dev/null +++ b/web/templates/pages/admin/repos/new.html @@ -0,0 +1,66 @@ +{{define "title"}}Add Repo{{end}} + +{{define "content"}} +
+ + +

Add Repo

+ + {{with .Data}} + {{if .Error}} +
+

{{.Error}}

+
+ {{end}} + {{end}} + +
+
+ + +
+ +
+ + +

Used in the webhook URL

+
+ +
+ + +
+ +
+ + +
+ +
+ + +

Must match the secret configured in Forgejo's webhook settings

+
+ +
+ + +
+ + +
+
+{{end}} diff --git a/web/templates/pages/admin/tickets/detail.html b/web/templates/pages/admin/tickets/detail.html new file mode 100644 index 0000000..a4a7e2b --- /dev/null +++ b/web/templates/pages/admin/tickets/detail.html @@ -0,0 +1,61 @@ +{{define "title"}}Ticket Detail{{end}} + +{{define "content"}} +{{with .Data}} +
+ ← Back to tickets +
+ +
+
+
+

{{.Ticket.Title}}

+

+ {{if .Repo}}{{.Repo.Name}} · {{end}} + {{if .User}}by {{.User.Email}} · {{end}} + Created {{formatDate .Ticket.CreatedAt}} + {{if .Ticket.ForgejoIssueNumber}} · Forgejo #{{.Ticket.ForgejoIssueNumber}}{{end}} +

+
+ {{statusBadge (print .Ticket.Status)}} +
+ +
+

{{.Ticket.Description}}

+
+ + +
+
+ + + +
+
+
+ + +
+

Comments

+ {{if .Comments}} +
+ {{range .Comments}} +
+
+ {{.UserName}} ({{.UserEmail}}) + {{formatDateTime .CreatedAt}} +
+

{{.Body}}

+
+ {{end}} +
+ {{else}} +

No comments.

+ {{end}} +
+{{end}} +{{end}} diff --git a/web/templates/pages/admin/tickets/list.html b/web/templates/pages/admin/tickets/list.html new file mode 100644 index 0000000..3daad12 --- /dev/null +++ b/web/templates/pages/admin/tickets/list.html @@ -0,0 +1,45 @@ +{{define "title"}}All Tickets{{end}} + +{{define "content"}} +

All Tickets

+ +{{with .Data}} +
+ All + Open + In Progress + Closed +
+ +{{if .Tickets}} +
+ + + + + + + + + + + + {{range .Tickets}} + + + + + + + + {{end}} + +
TitleUserProductStatusCreated
+ {{.Title}} + {{.UserEmail}}{{.RepoName}}{{statusBadge (print .Status)}}{{formatDate .CreatedAt}}
+
+{{else}} +

No tickets found.

+{{end}} +{{end}} +{{end}} diff --git a/web/templates/pages/admin/users/detail.html b/web/templates/pages/admin/users/detail.html new file mode 100644 index 0000000..3bcfc2e --- /dev/null +++ b/web/templates/pages/admin/users/detail.html @@ -0,0 +1,57 @@ +{{define "title"}}User Detail{{end}} + +{{define "content"}} +{{with .Data}} +
+ ← Back to users +
+ +
+

{{.User.Name}}

+
+
+
Email
+
{{.User.Email}}
+
+
+
Verified
+
{{if .User.EmailVerified}}Yes{{else}}No{{end}}
+
+
+
Created
+
{{formatDate .User.CreatedAt}}
+
+
+
+ +

Tickets

+{{if .Tickets}} +
+ + + + + + + + + + + {{range .Tickets}} + + + + + + + {{end}} + +
TitleProductStatusCreated
+ {{.Title}} + {{.RepoName}}{{statusBadge (print .Status)}}{{formatDate .CreatedAt}}
+
+{{else}} +

No tickets.

+{{end}} +{{end}} +{{end}} diff --git a/web/templates/pages/admin/users/list.html b/web/templates/pages/admin/users/list.html new file mode 100644 index 0000000..155c28c --- /dev/null +++ b/web/templates/pages/admin/users/list.html @@ -0,0 +1,41 @@ +{{define "title"}}Users{{end}} + +{{define "content"}} +
+

Users

+ Create User +
+ +{{with .Data}} +
+ + + + + + + + + + + {{range .Users}} + + + + + + + {{end}} + +
NameEmailVerifiedCreated
+ {{.Name}} + {{.Email}} + {{if .EmailVerified}} + Yes + {{else}} + No + {{end}} + {{formatDate .CreatedAt}}
+
+{{end}} +{{end}} diff --git a/web/templates/pages/admin/users/new.html b/web/templates/pages/admin/users/new.html new file mode 100644 index 0000000..fc0d051 --- /dev/null +++ b/web/templates/pages/admin/users/new.html @@ -0,0 +1,38 @@ +{{define "title"}}Create User{{end}} + +{{define "content"}} +
+ + +

Create Customer User

+

A temporary password will be generated and emailed to the user.

+ + {{with .Data}} + {{if .Error}} +
+

{{.Error}}

+
+ {{end}} + {{end}} + +
+
+ + +
+ +
+ + +
+ + +
+
+{{end}} diff --git a/web/templates/pages/forgot-password.html b/web/templates/pages/forgot-password.html new file mode 100644 index 0000000..87dddc1 --- /dev/null +++ b/web/templates/pages/forgot-password.html @@ -0,0 +1,32 @@ +{{define "title"}}Forgot Password{{end}} + +{{define "content"}} +
+

Reset your password

+

Enter your email and we'll send you a reset link.

+ + {{with .Data}} + {{if .Success}} +
+

{{.Success}}

+
+ {{end}} + {{end}} + +
+ + +
+ + +
+ + +
+ +

+ Back to login +

+
+{{end}} diff --git a/web/templates/pages/home.html b/web/templates/pages/home.html new file mode 100644 index 0000000..377dff5 --- /dev/null +++ b/web/templates/pages/home.html @@ -0,0 +1,17 @@ +{{define "title"}}Support Center{{end}} + +{{define "content"}} +
+

Support Center

+

Submit bug reports and track the status of your tickets.

+
+ {{if .User}} + Submit a Ticket + View My Tickets + {{else}} + Get Started + Sign In + {{end}} +
+
+{{end}} diff --git a/web/templates/pages/login.html b/web/templates/pages/login.html new file mode 100644 index 0000000..8807e19 --- /dev/null +++ b/web/templates/pages/login.html @@ -0,0 +1,51 @@ +{{define "title"}}Login{{end}} + +{{define "content"}} +
+

Sign in to your account

+ + {{with .Data}} + {{if .Error}} +
+

{{.Error}}

+
+ {{end}} + {{end}} + +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+
+
+
Or continue with
+
+ +
+ +

+ Don't have an account? Register + · Forgot password? +

+
+{{end}} diff --git a/web/templates/pages/register.html b/web/templates/pages/register.html new file mode 100644 index 0000000..f83e463 --- /dev/null +++ b/web/templates/pages/register.html @@ -0,0 +1,52 @@ +{{define "title"}}Register{{end}} + +{{define "content"}} +
+

Create your account

+ + {{with .Data}} + {{if .Error}} +
+

{{.Error}}

+
+ {{end}} + {{end}} + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +

Must be at least 8 characters

+
+ +
+ + +
+ + +
+ +

+ Already have an account? Sign in +

+
+{{end}} diff --git a/web/templates/pages/reset-password.html b/web/templates/pages/reset-password.html new file mode 100644 index 0000000..550844a --- /dev/null +++ b/web/templates/pages/reset-password.html @@ -0,0 +1,34 @@ +{{define "title"}}Reset Password{{end}} + +{{define "content"}} +
+

Set new password

+ + {{with .Data}} + {{if .Error}} +
+

{{.Error}}

+
+ {{end}} + {{end}} + +
+ + + +
+ + +
+ +
+ + +
+ + +
+
+{{end}} diff --git a/web/templates/pages/tickets/detail.html b/web/templates/pages/tickets/detail.html new file mode 100644 index 0000000..0cd6b00 --- /dev/null +++ b/web/templates/pages/tickets/detail.html @@ -0,0 +1,61 @@ +{{define "title"}}Ticket Detail{{end}} + +{{define "content"}} +{{with .Data}} +
+ ← Back to tickets +
+ +
+
+
+

{{.Ticket.Title}}

+

+ {{if .Repo}}{{.Repo.Name}} · {{end}} + Created {{formatDate .Ticket.CreatedAt}} +

+
+ {{statusBadge (print .Ticket.Status)}} +
+ +
+

{{.Ticket.Description}}

+
+
+ + +
+

Comments

+ + {{if .Comments}} +
+ {{range .Comments}} +
+
+ {{.UserName}} + {{formatDateTime .CreatedAt}} +
+

{{.Body}}

+
+ {{end}} +
+ {{else}} +

No comments yet.

+ {{end}} + + +
+ +
+ + +
+
+ +
+
+
+{{end}} +{{end}} diff --git a/web/templates/pages/tickets/list.html b/web/templates/pages/tickets/list.html new file mode 100644 index 0000000..d326260 --- /dev/null +++ b/web/templates/pages/tickets/list.html @@ -0,0 +1,42 @@ +{{define "title"}}My Tickets{{end}} + +{{define "content"}} +
+

My Tickets

+ New Ticket +
+ +{{with .Data}} + {{if .Tickets}} +
+ + + + + + + + + + + {{range .Tickets}} + + + + + + + {{end}} + +
TitleProductStatusCreated
+ {{.Title}} + {{.RepoName}}{{statusBadge (print .Status)}}{{formatDate .CreatedAt}}
+
+ {{else}} +
+

No tickets yet.

+ Create your first ticket +
+ {{end}} +{{end}} +{{end}} diff --git a/web/templates/pages/tickets/new.html b/web/templates/pages/tickets/new.html new file mode 100644 index 0000000..5b88676 --- /dev/null +++ b/web/templates/pages/tickets/new.html @@ -0,0 +1,52 @@ +{{define "title"}}New Ticket{{end}} + +{{define "content"}} +
+

Submit a Ticket

+ + {{with .Data}} + {{if .Error}} +
+

{{.Error}}

+
+ {{end}} + {{end}} + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ Cancel + +
+
+
+{{end}} diff --git a/web/templates/partials/flash.html b/web/templates/partials/flash.html new file mode 100644 index 0000000..b07e473 --- /dev/null +++ b/web/templates/partials/flash.html @@ -0,0 +1,19 @@ +{{define "flash"}} +{{if .Flash}} +
+ {{if eq .Flash.Type "success"}} +
+

{{.Flash.Message}}

+
+ {{else if eq .Flash.Type "error"}} +
+

{{.Flash.Message}}

+
+ {{else}} +
+

{{.Flash.Message}}

+
+ {{end}} +
+{{end}} +{{end}} diff --git a/web/templates/partials/nav.html b/web/templates/partials/nav.html new file mode 100644 index 0000000..4b056aa --- /dev/null +++ b/web/templates/partials/nav.html @@ -0,0 +1,23 @@ +{{define "nav"}} + +{{end}}