Init
This commit is contained in:
commit
4fa62fc164
|
|
@ -0,0 +1,5 @@
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.claude
|
||||||
|
*.md
|
||||||
|
forgejo-tickets
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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[:])
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"tailwindcss": "^4.1.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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">← 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}}
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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">← 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}}
|
||||||
|
|
@ -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">← 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}} · {{end}}
|
||||||
|
{{if .User}}by {{.User.Email}} · {{end}}
|
||||||
|
Created {{formatDate .Ticket.CreatedAt}}
|
||||||
|
{{if .Ticket.ForgejoIssueNumber}} · 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}}
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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">← 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}}
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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">← 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}}
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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>
|
||||||
|
· <a href="/forgot-password" class="font-medium text-blue-600 hover:text-blue-500">Forgot password?</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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">← 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}} · {{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}}
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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}}
|
||||||
|
|
@ -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}}
|
||||||
Loading…
Reference in New Issue