feat: add profile sync

This commit is contained in:
Philipp
2026-06-10 16:32:46 +02:00
parent 9947286b1e
commit 000c08dae7
11 changed files with 251 additions and 8 deletions
+34 -4
View File
@@ -2,18 +2,22 @@ package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"proxui/backend/internal/auth"
"forgejo.digital-droplets.de/philschlo/proxui/platform/config"
"forgejo.digital-droplets.de/philschlo/proxui/platform/logging"
_ "github.com/jackc/pgx/v5/stdlib"
"proxui/backend/internal/auth"
"proxui/backend/internal/profile"
)
func main() {
@@ -35,17 +39,26 @@ func main() {
os.Exit(1)
}
authMiddleware := auth.NewMiddleware(jwtValidator)
db, err := openDatabase(cfg.DatabaseURL)
if err != nil {
logger.Error("failed to connect database", "error", err)
os.Exit(1)
}
defer db.Close()
profileMiddleware := profile.NewMiddleware(profile.NewRepository(db), logger)
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","service":"backend"}`))
})
mux.Handle("GET /me", authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
meHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, _ := auth.PrincipalFromRequest(r)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(principal)
})))
})
mux.Handle("GET /me", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(meHandler)))
server := &http.Server{
Addr: cfg.BackendAddr,
@@ -73,3 +86,20 @@ func main() {
os.Exit(1)
}
}
func openDatabase(databaseURL string) (*sql.DB, error) {
if databaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, err
}
return db, nil
}
+11
View File
@@ -1,3 +1,14 @@
module proxui/backend
go 1.22
require github.com/jackc/pgx/v5 v5.7.6
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
+21
View File
@@ -0,0 +1,21 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
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=
+1 -1
View File
@@ -28,7 +28,7 @@ func (m Middleware) RequireAuth(next http.Handler) http.Handler {
return
}
next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), principal)))
next.ServeHTTP(w, r.WithContext(ContextWithPrincipal(r.Context(), principal)))
})
}
+1 -1
View File
@@ -18,7 +18,7 @@ func PrincipalFromContext(ctx context.Context) (Principal, bool) {
return principal, ok
}
func withPrincipal(ctx context.Context, principal Principal) context.Context {
func ContextWithPrincipal(ctx context.Context, principal Principal) context.Context {
return context.WithValue(ctx, principalContextKey{}, principal)
}
+50
View File
@@ -0,0 +1,50 @@
package profile
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"proxui/backend/internal/auth"
)
type Ensurer interface {
Ensure(ctx context.Context, id string, email string) error
}
type Middleware struct {
ensurer Ensurer
logger *slog.Logger
}
func NewMiddleware(ensurer Ensurer, logger *slog.Logger) Middleware {
return Middleware{
ensurer: ensurer,
logger: logger,
}
}
func (m Middleware) EnsureProfile(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
if err := m.ensurer.Ensure(r.Context(), principal.Subject, principal.Email); err != nil {
m.logger.Error("profile sync failed", "error", err)
writeError(w, http.StatusInternalServerError, "profile_sync_failed")
return
}
next.ServeHTTP(w, r)
})
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
@@ -0,0 +1,99 @@
package profile
import (
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"proxui/backend/internal/auth"
)
func TestEnsureProfileCreatesMissingProfile(t *testing.T) {
ensurer := &stubEnsurer{}
middleware := NewMiddleware(ensurer, slog.Default())
handler := middleware.EnsureProfile(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/me", nil)
req = req.WithContext(auth.ContextWithPrincipal(req.Context(), auth.Principal{
Subject: "00000000-0000-0000-0000-000000000001",
Email: "user@example.test",
Role: "authenticated",
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
if ensurer.calls != 1 {
t.Fatalf("Ensure calls = %d, want 1", ensurer.calls)
}
if ensurer.id != "00000000-0000-0000-0000-000000000001" {
t.Fatalf("id = %q", ensurer.id)
}
if ensurer.email != "user@example.test" {
t.Fatalf("email = %q", ensurer.email)
}
}
func TestEnsureProfileRejectsMissingPrincipal(t *testing.T) {
ensurer := &stubEnsurer{}
middleware := NewMiddleware(ensurer, slog.Default())
handler := middleware.EnsureProfile(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/me", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
if ensurer.calls != 0 {
t.Fatalf("Ensure calls = %d, want 0", ensurer.calls)
}
}
func TestEnsureProfileReturnsServerErrorOnSyncFailure(t *testing.T) {
ensurer := &stubEnsurer{err: errors.New("db failed")}
middleware := NewMiddleware(ensurer, slog.Default())
handler := middleware.EnsureProfile(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/me", nil)
req = req.WithContext(auth.ContextWithPrincipal(req.Context(), auth.Principal{
Subject: "00000000-0000-0000-0000-000000000001",
Email: "user@example.test",
Role: "authenticated",
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
}
type stubEnsurer struct {
calls int
id string
email string
err error
}
func (s *stubEnsurer) Ensure(_ context.Context, id string, email string) error {
s.calls++
s.id = id
s.email = email
return s.err
}
+25
View File
@@ -0,0 +1,25 @@
package profile
import (
"context"
"database/sql"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) Repository {
return Repository{db: db}
}
func (r Repository) Ensure(ctx context.Context, id string, email string) error {
_, err := r.db.ExecContext(ctx, `
insert into public.profiles (id, email)
values ($1, $2)
on conflict (id) do update
set email = excluded.email
where public.profiles.email is distinct from excluded.email
`, id, email)
return err
}