feat: add profile sync
This commit is contained in:
+34
-4
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user