This commit is contained in:
Matthew Knight 2026-02-12 15:00:17 -08:00
commit 4fa62fc164
No known key found for this signature in database
67 changed files with 4931 additions and 0 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
.git
.env
.claude
*.md
forgejo-tickets

36
.env.example Normal file
View File

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

30
.gitignore vendored Normal file
View File

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

26
Dockerfile Normal file
View File

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

32
Makefile Normal file
View File

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

122
cmd/server/main.go Normal file
View File

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

59
go.mod Normal file
View File

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

137
go.sum Normal file
View File

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

153
internal/auth/apple.go Normal file
View File

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

117
internal/auth/auth.go Normal file
View File

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

152
internal/auth/oauth.go Normal file
View File

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

75
internal/auth/session.go Normal file
View File

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

158
internal/auth/store.go Normal file
View File

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

91
internal/auth/tokens.go Normal file
View File

@ -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[:])
}

View File

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

92
internal/config/config.go Normal file
View File

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

View File

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

View File

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

106
internal/email/email.go Normal file
View File

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

View File

@ -0,0 +1,67 @@
package email
import "fmt"
func emailWrapper(content string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #1a1a1a;">
%s
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 30px 0;">
<p style="color: #666; font-size: 13px;">This is an automated message. Please do not reply directly to this email.</p>
</body>
</html>`, content)
}
func renderVerificationEmail(name, verifyURL string) string {
return emailWrapper(fmt.Sprintf(`
<h2 style="color: #111;">Verify your email address</h2>
<p>Hi %s,</p>
<p>Please verify your email address by clicking the button below:</p>
<p style="margin: 30px 0;">
<a href="%s" style="background: #2563eb; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500;">Verify Email</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #2563eb;">%s</p>
<p>This link expires in 24 hours.</p>`, name, verifyURL, verifyURL))
}
func renderPasswordResetEmail(name, resetURL string) string {
return emailWrapper(fmt.Sprintf(`
<h2 style="color: #111;">Reset your password</h2>
<p>Hi %s,</p>
<p>We received a request to reset your password. Click the button below to set a new one:</p>
<p style="margin: 30px 0;">
<a href="%s" style="background: #2563eb; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500;">Reset Password</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #2563eb;">%s</p>
<p>This link expires in 1 hour. If you didn't request this, please ignore this email.</p>`, name, resetURL, resetURL))
}
func renderTicketClosedEmail(name, ticketTitle, ticketURL string) string {
return emailWrapper(fmt.Sprintf(`
<h2 style="color: #111;">Your ticket has been resolved</h2>
<p>Hi %s,</p>
<p>Your ticket <strong>"%s"</strong> has been resolved by our team.</p>
<p style="margin: 30px 0;">
<a href="%s" style="background: #2563eb; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500;">View Ticket</a>
</p>
<p>If you believe the issue is not fully resolved, you can add a comment on the ticket page.</p>`, name, ticketTitle, ticketURL))
}
func renderWelcomeEmail(name, email, tempPassword, loginURL string) string {
return emailWrapper(fmt.Sprintf(`
<h2 style="color: #111;">Welcome!</h2>
<p>Hi %s,</p>
<p>An account has been created for you. Here are your login details:</p>
<table style="margin: 20px 0; border-collapse: collapse;">
<tr><td style="padding: 8px 16px 8px 0; font-weight: 600;">Email:</td><td style="padding: 8px 0;">%s</td></tr>
<tr><td style="padding: 8px 16px 8px 0; font-weight: 600;">Temporary Password:</td><td style="padding: 8px 0; font-family: monospace; background: #f5f5f5; padding: 4px 8px; border-radius: 4px;">%s</td></tr>
</table>
<p style="margin: 30px 0;">
<a href="%s" style="background: #2563eb; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500;">Log In</a>
</p>
<p>Please change your password after logging in.</p>`, name, email, tempPassword, loginURL))
}

116
internal/forgejo/client.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

127
internal/models/models.go Normal file
View File

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

View File

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

View File

@ -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(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium %s">%s</span>`, 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
},
}
}

View File

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

19
package-lock.json generated Normal file
View File

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

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"devDependencies": {
"tailwindcss": "^4.1.18"
}
}

1
web/static/css/input.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-gray-50">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Admin{{end}}</title>
<link rel="stylesheet" href="/static/css/output.css">
</head>
<body class="h-full">
<div class="min-h-full">
<nav class="bg-gray-900">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center gap-8">
<span class="text-white font-bold text-lg">Admin Panel</span>
<div class="flex gap-4">
<a href="/" class="text-gray-300 hover:text-white text-sm font-medium">Dashboard</a>
<a href="/users" class="text-gray-300 hover:text-white text-sm font-medium">Users</a>
<a href="/tickets" class="text-gray-300 hover:text-white text-sm font-medium">Tickets</a>
<a href="/repos" class="text-gray-300 hover:text-white text-sm font-medium">Repos</a>
</div>
</div>
</div>
</div>
</nav>
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{{block "content" .}}{{end}}
</main>
</div>
</body>
</html>

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-gray-50">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Support{{end}}</title>
<link rel="stylesheet" href="/static/css/output.css">
</head>
<body class="h-full">
<div class="min-h-full">
{{template "nav" .}}
{{template "flash" .}}
<main class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
{{block "content" .}}{{end}}
</main>
</div>
</body>
</html>

View File

@ -0,0 +1,26 @@
{{define "title"}}Admin Dashboard{{end}}
{{define "content"}}
<h1 class="text-2xl font-bold text-gray-900 mb-8">Dashboard</h1>
{{with .Data}}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div class="bg-white overflow-hidden rounded-lg shadow ring-1 ring-gray-200 p-6">
<dt class="text-sm font-medium text-gray-500">Total Users</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">{{.UserCount}}</dd>
</div>
<div class="bg-white overflow-hidden rounded-lg shadow ring-1 ring-gray-200 p-6">
<dt class="text-sm font-medium text-gray-500">Open Tickets</dt>
<dd class="mt-1 text-3xl font-semibold text-yellow-600">{{.OpenTickets}}</dd>
</div>
<div class="bg-white overflow-hidden rounded-lg shadow ring-1 ring-gray-200 p-6">
<dt class="text-sm font-medium text-gray-500">In Progress</dt>
<dd class="mt-1 text-3xl font-semibold text-blue-600">{{.InProgressTickets}}</dd>
</div>
<div class="bg-white overflow-hidden rounded-lg shadow ring-1 ring-gray-200 p-6">
<dt class="text-sm font-medium text-gray-500">Closed Tickets</dt>
<dd class="mt-1 text-3xl font-semibold text-green-600">{{.ClosedTickets}}</dd>
</div>
</div>
{{end}}
{{end}}

View File

@ -0,0 +1,61 @@
{{define "title"}}Edit Repo{{end}}
{{define "content"}}
{{with .Data}}
<div class="mx-auto max-w-lg">
<div class="mb-4">
<a href="/repos" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to repos</a>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Edit Repo</h1>
<div class="mb-6 rounded-md bg-blue-50 p-4">
<p class="text-sm text-blue-800">
<strong>Webhook URL:</strong>
<code class="ml-1 font-mono text-xs">{{.BaseURL}}/webhooks/forgejo/{{.Repo.Slug}}</code>
</p>
<p class="mt-1 text-xs text-blue-600">Configure this URL in Forgejo's webhook settings for this repo.</p>
</div>
<form method="POST" action="/repos/{{.Repo.ID}}" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Display Name</label>
<input type="text" name="name" id="name" required value="{{.Repo.Name}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700">Slug</label>
<input type="text" name="slug" id="slug" required value="{{.Repo.Slug}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="forgejo_owner" class="block text-sm font-medium text-gray-700">Forgejo Owner</label>
<input type="text" name="forgejo_owner" id="forgejo_owner" required value="{{.Repo.ForgejoOwner}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="forgejo_repo" class="block text-sm font-medium text-gray-700">Forgejo Repo</label>
<input type="text" name="forgejo_repo" id="forgejo_repo" required value="{{.Repo.ForgejoRepo}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="webhook_secret" class="block text-sm font-medium text-gray-700">Webhook Secret</label>
<input type="text" name="webhook_secret" id="webhook_secret" required value="{{.Repo.WebhookSecret}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div class="flex items-center gap-2">
<input type="checkbox" name="active" id="active" {{if .Repo.Active}}checked{{end}}
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<label for="active" class="text-sm font-medium text-gray-700">Active</label>
</div>
<button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Save Changes</button>
</form>
</div>
{{end}}
{{end}}

View File

@ -0,0 +1,47 @@
{{define "title"}}Repos{{end}}
{{define "content"}}
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Repos</h1>
<a href="/repos/new" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Add Repo</a>
</div>
{{with .Data}}
{{if .Repos}}
<div class="overflow-hidden bg-white shadow ring-1 ring-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Forgejo</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Webhook URL</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Active</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .Repos}}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{.Name}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{.ForgejoOwner}}/{{.ForgejoRepo}}</td>
<td class="px-4 py-3 text-sm text-gray-500 font-mono text-xs">/webhooks/forgejo/{{.Slug}}</td>
<td class="px-4 py-3 text-sm">
{{if .Active}}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>
{{else}}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Inactive</span>
{{end}}
</td>
<td class="px-4 py-3 text-sm">
<a href="/repos/{{.ID}}/edit" class="text-blue-600 hover:text-blue-500">Edit</a>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-sm text-gray-500">No repos configured. Add one to get started.</p>
{{end}}
{{end}}
{{end}}

View File

@ -0,0 +1,66 @@
{{define "title"}}Add Repo{{end}}
{{define "content"}}
<div class="mx-auto max-w-lg">
<div class="mb-4">
<a href="/repos" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to repos</a>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Add Repo</h1>
{{with .Data}}
{{if .Error}}
<div class="mb-4 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{{.Error}}</p>
</div>
{{end}}
{{end}}
<form method="POST" action="/repos" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Display Name</label>
<input type="text" name="name" id="name" required placeholder="Billing App"
value="{{with .Data}}{{.Name}}{{end}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700">Slug</label>
<input type="text" name="slug" id="slug" required placeholder="billing-app"
value="{{with .Data}}{{.Slug}}{{end}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<p class="mt-1 text-xs text-gray-500">Used in the webhook URL</p>
</div>
<div>
<label for="forgejo_owner" class="block text-sm font-medium text-gray-700">Forgejo Owner</label>
<input type="text" name="forgejo_owner" id="forgejo_owner" required
value="{{with .Data}}{{.ForgejoOwner}}{{end}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="forgejo_repo" class="block text-sm font-medium text-gray-700">Forgejo Repo</label>
<input type="text" name="forgejo_repo" id="forgejo_repo" required
value="{{with .Data}}{{.ForgejoRepo}}{{end}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="webhook_secret" class="block text-sm font-medium text-gray-700">Webhook Secret</label>
<input type="text" name="webhook_secret" id="webhook_secret" required
value="{{with .Data}}{{.WebhookSecret}}{{end}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<p class="mt-1 text-xs text-gray-500">Must match the secret configured in Forgejo's webhook settings</p>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" name="active" id="active" checked
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<label for="active" class="text-sm font-medium text-gray-700">Active</label>
</div>
<button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Add Repo</button>
</form>
</div>
{{end}}

View File

@ -0,0 +1,61 @@
{{define "title"}}Ticket Detail{{end}}
{{define "content"}}
{{with .Data}}
<div class="mb-4">
<a href="/tickets" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to tickets</a>
</div>
<div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<div class="flex items-start justify-between">
<div>
<h1 class="text-xl font-bold text-gray-900">{{.Ticket.Title}}</h1>
<p class="mt-1 text-sm text-gray-500">
{{if .Repo}}{{.Repo.Name}} &middot; {{end}}
{{if .User}}by {{.User.Email}} &middot; {{end}}
Created {{formatDate .Ticket.CreatedAt}}
{{if .Ticket.ForgejoIssueNumber}} &middot; Forgejo #{{.Ticket.ForgejoIssueNumber}}{{end}}
</p>
</div>
{{statusBadge (print .Ticket.Status)}}
</div>
<div class="mt-6 prose prose-sm max-w-none text-gray-700">
<p class="whitespace-pre-wrap">{{.Ticket.Description}}</p>
</div>
<!-- Status Update -->
<div class="mt-6 pt-4 border-t border-gray-200">
<form method="POST" action="/tickets/{{.Ticket.ID}}/status" class="flex items-center gap-3">
<label for="status" class="text-sm font-medium text-gray-700">Update Status:</label>
<select name="status" id="status" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<option value="open" {{if eq (print .Ticket.Status) "open"}}selected{{end}}>Open</option>
<option value="in_progress" {{if eq (print .Ticket.Status) "in_progress"}}selected{{end}}>In Progress</option>
<option value="closed" {{if eq (print .Ticket.Status) "closed"}}selected{{end}}>Closed</option>
</select>
<button type="submit" class="rounded-md bg-gray-900 px-3 py-1.5 text-sm font-semibold text-white shadow hover:bg-gray-800">Update</button>
</form>
</div>
</div>
<!-- Comments -->
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Comments</h2>
{{if .Comments}}
<div class="space-y-4">
{{range .Comments}}
<div class="bg-white p-4 rounded-lg shadow ring-1 ring-gray-200">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-900">{{.UserName}} ({{.UserEmail}})</span>
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
</div>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{.Body}}</p>
</div>
{{end}}
</div>
{{else}}
<p class="text-sm text-gray-500">No comments.</p>
{{end}}
</div>
{{end}}
{{end}}

View File

@ -0,0 +1,45 @@
{{define "title"}}All Tickets{{end}}
{{define "content"}}
<h1 class="text-2xl font-bold text-gray-900 mb-6">All Tickets</h1>
{{with .Data}}
<div class="mb-4 flex gap-2">
<a href="/tickets" class="rounded-md px-3 py-1.5 text-sm font-medium {{if not .StatusFilter}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">All</a>
<a href="/tickets?status=open" class="rounded-md px-3 py-1.5 text-sm font-medium {{if eq .StatusFilter "open"}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">Open</a>
<a href="/tickets?status=in_progress" class="rounded-md px-3 py-1.5 text-sm font-medium {{if eq .StatusFilter "in_progress"}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">In Progress</a>
<a href="/tickets?status=closed" class="rounded-md px-3 py-1.5 text-sm font-medium {{if eq .StatusFilter "closed"}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">Closed</a>
</div>
{{if .Tickets}}
<div class="overflow-hidden bg-white shadow ring-1 ring-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Product</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .Tickets}}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
</td>
<td class="px-4 py-3 text-sm text-gray-500">{{.UserEmail}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{.RepoName}}</td>
<td class="px-4 py-3">{{statusBadge (print .Status)}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-sm text-gray-500">No tickets found.</p>
{{end}}
{{end}}
{{end}}

View File

@ -0,0 +1,57 @@
{{define "title"}}User Detail{{end}}
{{define "content"}}
{{with .Data}}
<div class="mb-4">
<a href="/users" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to users</a>
</div>
<div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200 mb-8">
<h1 class="text-xl font-bold text-gray-900">{{.User.Name}}</h1>
<dl class="mt-4 grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="font-medium text-gray-500">Email</dt>
<dd class="text-gray-900">{{.User.Email}}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Verified</dt>
<dd>{{if .User.EmailVerified}}<span class="text-green-600">Yes</span>{{else}}<span class="text-red-600">No</span>{{end}}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Created</dt>
<dd class="text-gray-900">{{formatDate .User.CreatedAt}}</dd>
</div>
</dl>
</div>
<h2 class="text-lg font-semibold text-gray-900 mb-4">Tickets</h2>
{{if .Tickets}}
<div class="overflow-hidden bg-white shadow ring-1 ring-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Product</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .Tickets}}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
</td>
<td class="px-4 py-3 text-sm text-gray-500">{{.RepoName}}</td>
<td class="px-4 py-3">{{statusBadge (print .Status)}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-sm text-gray-500">No tickets.</p>
{{end}}
{{end}}
{{end}}

View File

@ -0,0 +1,41 @@
{{define "title"}}Users{{end}}
{{define "content"}}
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Users</h1>
<a href="/users/new" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Create User</a>
</div>
{{with .Data}}
<div class="overflow-hidden bg-white shadow ring-1 ring-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Verified</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .Users}}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href="/users/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Name}}</a>
</td>
<td class="px-4 py-3 text-sm text-gray-500">{{.Email}}</td>
<td class="px-4 py-3 text-sm">
{{if .EmailVerified}}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Yes</span>
{{else}}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">No</span>
{{end}}
</td>
<td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{end}}

View File

@ -0,0 +1,38 @@
{{define "title"}}Create User{{end}}
{{define "content"}}
<div class="mx-auto max-w-lg">
<div class="mb-4">
<a href="/users" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to users</a>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Create Customer User</h1>
<p class="text-sm text-gray-500 mb-6">A temporary password will be generated and emailed to the user.</p>
{{with .Data}}
{{if .Error}}
<div class="mb-4 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{{.Error}}</p>
</div>
{{end}}
{{end}}
<form method="POST" action="/users" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Name</label>
<input type="text" name="name" id="name" required
value="{{with .Data}}{{.Name}}{{end}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" name="email" id="email" required
value="{{with .Data}}{{.Email}}{{end}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Create User & Send Welcome Email</button>
</form>
</div>
{{end}}

View File

@ -0,0 +1,32 @@
{{define "title"}}Forgot Password{{end}}
{{define "content"}}
<div class="mx-auto max-w-sm">
<h2 class="text-2xl font-bold text-gray-900 text-center">Reset your password</h2>
<p class="mt-2 text-center text-sm text-gray-600">Enter your email and we'll send you a reset link.</p>
{{with .Data}}
{{if .Success}}
<div class="mt-4 rounded-md bg-green-50 p-4">
<p class="text-sm text-green-800">{{.Success}}</p>
</div>
{{end}}
{{end}}
<form method="POST" action="/forgot-password" class="mt-8 space-y-6">
<input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" name="email" id="email" required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Send reset link</button>
</form>
<p class="mt-6 text-center text-sm text-gray-500">
<a href="/login" class="font-medium text-blue-600 hover:text-blue-500">Back to login</a>
</p>
</div>
{{end}}

View File

@ -0,0 +1,17 @@
{{define "title"}}Support Center{{end}}
{{define "content"}}
<div class="text-center py-16">
<h1 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">Support Center</h1>
<p class="mt-4 text-lg text-gray-600">Submit bug reports and track the status of your tickets.</p>
<div class="mt-8 flex justify-center gap-4">
{{if .User}}
<a href="/tickets/new" class="rounded-md bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow hover:bg-blue-500">Submit a Ticket</a>
<a href="/tickets" class="rounded-md bg-white px-4 py-2.5 text-sm font-semibold text-gray-900 shadow ring-1 ring-inset ring-gray-300 hover:bg-gray-50">View My Tickets</a>
{{else}}
<a href="/register" class="rounded-md bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow hover:bg-blue-500">Get Started</a>
<a href="/login" class="rounded-md bg-white px-4 py-2.5 text-sm font-semibold text-gray-900 shadow ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Sign In</a>
{{end}}
</div>
</div>
{{end}}

View File

@ -0,0 +1,51 @@
{{define "title"}}Login{{end}}
{{define "content"}}
<div class="mx-auto max-w-sm">
<h2 class="text-2xl font-bold text-gray-900 text-center">Sign in to your account</h2>
{{with .Data}}
{{if .Error}}
<div class="mt-4 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{{.Error}}</p>
</div>
{{end}}
{{end}}
<form method="POST" action="/login" class="mt-8 space-y-6">
<input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" name="email" id="email" required
value="{{with .Data}}{{.Email}}{{end}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input type="password" name="password" id="password" required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Sign in</button>
</form>
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-gray-300"></div></div>
<div class="relative flex justify-center text-sm"><span class="bg-gray-50 px-2 text-gray-500">Or continue with</span></div>
</div>
<div class="mt-6 grid grid-cols-3 gap-3">
<a href="/auth/google/login" class="flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50">Google</a>
<a href="/auth/microsoft/login" class="flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50">Microsoft</a>
<a href="/auth/apple/login" class="flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50">Apple</a>
</div>
</div>
<p class="mt-6 text-center text-sm text-gray-500">
Don't have an account? <a href="/register" class="font-medium text-blue-600 hover:text-blue-500">Register</a>
&middot; <a href="/forgot-password" class="font-medium text-blue-600 hover:text-blue-500">Forgot password?</a>
</p>
</div>
{{end}}

View File

@ -0,0 +1,52 @@
{{define "title"}}Register{{end}}
{{define "content"}}
<div class="mx-auto max-w-sm">
<h2 class="text-2xl font-bold text-gray-900 text-center">Create your account</h2>
{{with .Data}}
{{if .Error}}
<div class="mt-4 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{{.Error}}</p>
</div>
{{end}}
{{end}}
<form method="POST" action="/register" class="mt-8 space-y-6">
<input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Name</label>
<input type="text" name="name" id="name" required
value="{{with .Data}}{{.Name}}{{end}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" name="email" id="email" required
value="{{with .Data}}{{.Email}}{{end}}"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input type="password" name="password" id="password" required minlength="8"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<p class="mt-1 text-xs text-gray-500">Must be at least 8 characters</p>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700">Confirm Password</label>
<input type="password" name="confirm_password" id="confirm_password" required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Create account</button>
</form>
<p class="mt-6 text-center text-sm text-gray-500">
Already have an account? <a href="/login" class="font-medium text-blue-600 hover:text-blue-500">Sign in</a>
</p>
</div>
{{end}}

View File

@ -0,0 +1,34 @@
{{define "title"}}Reset Password{{end}}
{{define "content"}}
<div class="mx-auto max-w-sm">
<h2 class="text-2xl font-bold text-gray-900 text-center">Set new password</h2>
{{with .Data}}
{{if .Error}}
<div class="mt-4 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{{.Error}}</p>
</div>
{{end}}
{{end}}
<form method="POST" action="/reset-password" class="mt-8 space-y-6">
<input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}">
<input type="hidden" name="token" value="{{with .Data}}{{.Token}}{{end}}">
<div>
<label for="password" class="block text-sm font-medium text-gray-700">New Password</label>
<input type="password" name="password" id="password" required minlength="8"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700">Confirm New Password</label>
<input type="password" name="confirm_password" id="confirm_password" required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<button type="submit" class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Reset password</button>
</form>
</div>
{{end}}

View File

@ -0,0 +1,61 @@
{{define "title"}}Ticket Detail{{end}}
{{define "content"}}
{{with .Data}}
<div class="mb-4">
<a href="/tickets" class="text-sm text-blue-600 hover:text-blue-500">&larr; Back to tickets</a>
</div>
<div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<div class="flex items-start justify-between">
<div>
<h1 class="text-xl font-bold text-gray-900">{{.Ticket.Title}}</h1>
<p class="mt-1 text-sm text-gray-500">
{{if .Repo}}{{.Repo.Name}} &middot; {{end}}
Created {{formatDate .Ticket.CreatedAt}}
</p>
</div>
{{statusBadge (print .Ticket.Status)}}
</div>
<div class="mt-6 prose prose-sm max-w-none text-gray-700">
<p class="whitespace-pre-wrap">{{.Ticket.Description}}</p>
</div>
</div>
<!-- Comments -->
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Comments</h2>
{{if .Comments}}
<div class="space-y-4">
{{range .Comments}}
<div class="bg-white p-4 rounded-lg shadow ring-1 ring-gray-200">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-900">{{.UserName}}</span>
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
</div>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{.Body}}</p>
</div>
{{end}}
</div>
{{else}}
<p class="text-sm text-gray-500">No comments yet.</p>
{{end}}
<!-- Add Comment -->
<form method="POST" action="/tickets/{{.Ticket.ID}}/comments" class="mt-6">
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<div>
<label for="body" class="sr-only">Add a comment</label>
<textarea name="body" id="body" rows="3" required
placeholder="Add a comment..."
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"></textarea>
</div>
<div class="mt-3 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Add Comment</button>
</div>
</form>
</div>
{{end}}
{{end}}

View File

@ -0,0 +1,42 @@
{{define "title"}}My Tickets{{end}}
{{define "content"}}
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">My Tickets</h1>
<a href="/tickets/new" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">New Ticket</a>
</div>
{{with .Data}}
{{if .Tickets}}
<div class="overflow-hidden bg-white shadow ring-1 ring-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Product</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .Tickets}}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
</td>
<td class="px-4 py-3 text-sm text-gray-500">{{.RepoName}}</td>
<td class="px-4 py-3">{{statusBadge (print .Status)}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-12 bg-white rounded-lg shadow ring-1 ring-gray-200">
<p class="text-gray-500">No tickets yet.</p>
<a href="/tickets/new" class="mt-4 inline-block text-sm font-medium text-blue-600 hover:text-blue-500">Create your first ticket</a>
</div>
{{end}}
{{end}}
{{end}}

View File

@ -0,0 +1,52 @@
{{define "title"}}New Ticket{{end}}
{{define "content"}}
<div class="mx-auto max-w-2xl">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Submit a Ticket</h1>
{{with .Data}}
{{if .Error}}
<div class="mb-4 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{{.Error}}</p>
</div>
{{end}}
{{end}}
<form method="POST" action="/tickets" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}">
<div>
<label for="repo_id" class="block text-sm font-medium text-gray-700">Product</label>
<select name="repo_id" id="repo_id" required
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<option value="">Select a product...</option>
{{with .Data}}
{{range .Repos}}
<option value="{{.ID}}" {{if eq (print .ID) (index $ "RepoID")}}selected{{end}}>{{.Name}}</option>
{{end}}
{{end}}
</select>
</div>
<div>
<label for="title" class="block text-sm font-medium text-gray-700">Title</label>
<input type="text" name="title" id="title" required
value="{{with .Data}}{{.Title}}{{end}}"
placeholder="Brief summary of the issue"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Description</label>
<textarea name="description" id="description" rows="6" required
placeholder="Describe the issue in detail. Include steps to reproduce, expected behavior, and actual behavior."
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">{{with .Data}}{{.Description}}{{end}}</textarea>
</div>
<div class="flex justify-end gap-3">
<a href="/tickets" class="rounded-md bg-white px-4 py-2 text-sm font-semibold text-gray-900 shadow ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Cancel</a>
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Submit Ticket</button>
</div>
</form>
</div>
{{end}}

View File

@ -0,0 +1,19 @@
{{define "flash"}}
{{if .Flash}}
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 mt-4">
{{if eq .Flash.Type "success"}}
<div class="rounded-md bg-green-50 p-4">
<p class="text-sm font-medium text-green-800">{{.Flash.Message}}</p>
</div>
{{else if eq .Flash.Type "error"}}
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm font-medium text-red-800">{{.Flash.Message}}</p>
</div>
{{else}}
<div class="rounded-md bg-blue-50 p-4">
<p class="text-sm font-medium text-blue-800">{{.Flash.Message}}</p>
</div>
{{end}}
</div>
{{end}}
{{end}}

View File

@ -0,0 +1,23 @@
{{define "nav"}}
<nav class="bg-white shadow">
<div class="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<a href="/" class="text-xl font-bold text-gray-900">Support</a>
<div class="flex items-center gap-4">
{{if .User}}
<a href="/tickets" class="text-sm font-medium text-gray-700 hover:text-gray-900">My Tickets</a>
<a href="/tickets/new" class="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-md">New Ticket</a>
<span class="text-sm text-gray-500">{{.User.Email}}</span>
<form method="POST" action="/logout" class="inline">
<input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}">
<button type="submit" class="text-sm font-medium text-gray-500 hover:text-gray-700">Logout</button>
</form>
{{else}}
<a href="/login" class="text-sm font-medium text-gray-700 hover:text-gray-900">Login</a>
<a href="/register" class="text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-md">Register</a>
{{end}}
</div>
</div>
</div>
</nav>
{{end}}