feat: add Google Sign-In with JWT auth and Neon DB persistence

This commit is contained in:
user
2026-04-27 13:23:47 +09:00
parent 7f77c2aaf4
commit 3aa1d92c98
11 changed files with 393 additions and 19 deletions

34
go.mod
View File

@@ -2,13 +2,39 @@ module tutor
go 1.25.0
require github.com/jackc/pgx/v5 v5.9.2
require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/joho/godotenv v1.5.1
google.golang.org/api v0.276.0
)
require (
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
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
github.com/joho/godotenv v1.5.1 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

75
go.sum
View File

@@ -1,6 +1,33 @@
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
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/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
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=
@@ -18,10 +45,50 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY=
google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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=

View File

@@ -4,6 +4,8 @@ import (
"log"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
"tutor/internal/auth"
"tutor/internal/config"
"tutor/internal/db"
"tutor/internal/httpapi"
@@ -22,12 +24,14 @@ func NewServer(cfg config.Config) *http.Server {
var memoryStore learnermemory.Store
var ontologyStore ontology.Store
var assetsStore teachingassets.Store
var pool *pgxpool.Pool
if cfg.DatabaseURL != "" {
pool, err := db.Open(cfg.DatabaseURL)
p, err := db.Open(cfg.DatabaseURL)
if err != nil {
log.Fatalf("open database: %v", err)
}
pool = p
if err := db.Migrate(pool); err != nil {
log.Fatalf("migrate database: %v", err)
}
@@ -51,8 +55,15 @@ func NewServer(cfg config.Config) *http.Server {
service := interview.NewService(interviewStore, runner, memory)
handler := httpapi.NewHandler(cfg, service, memory, progress, onto, assets)
mux := handler.Routes().(*http.ServeMux)
if pool != nil && cfg.GoogleClientID != "" && cfg.JWTSecret != "" {
authService := auth.NewService(pool, cfg.GoogleClientID, cfg.JWTSecret)
authService.RegisterRoutes(mux)
log.Println("auth routes registered")
}
return &http.Server{
Addr: cfg.HTTPAddr,
Handler: handler.Routes(),
Handler: mux,
}
}

35
internal/auth/handler.go Normal file
View File

@@ -0,0 +1,35 @@
package auth
import (
"encoding/json"
"net/http"
)
func (s *Service) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/auth/google", s.handleGoogleLogin)
}
func (s *Service) handleGoogleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req GoogleLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, "invalid request", http.StatusBadRequest)
return
}
resp, err := s.HandleGoogleLogin(r.Context(), req)
if err != nil {
writeError(w, err.Error(), http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func writeError(w http.ResponseWriter, message string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}

128
internal/auth/service.go Normal file
View File

@@ -0,0 +1,128 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"google.golang.org/api/idtoken"
)
type Service struct {
pool *pgxpool.Pool
googleClientID string
jwtSecret string
}
func NewService(pool *pgxpool.Pool, googleClientID, jwtSecret string) *Service {
return &Service{
pool: pool,
googleClientID: googleClientID,
jwtSecret: jwtSecret,
}
}
func (s *Service) HandleGoogleLogin(ctx context.Context, req GoogleLoginRequest) (SessionResponse, error) {
if strings.TrimSpace(req.IDToken) == "" || s.googleClientID == "" {
return SessionResponse{}, errors.New("google id token is required")
}
payload, err := idtoken.Validate(ctx, req.IDToken, s.googleClientID)
if err != nil {
return SessionResponse{}, err
}
email := ""
if v, ok := payload.Claims["email"].(string); ok {
email = strings.ToLower(strings.TrimSpace(v))
}
name := ""
if v, ok := payload.Claims["name"].(string); ok {
name = strings.TrimSpace(v)
}
subject := payload.Subject
if email == "" {
return SessionResponse{}, errors.New("email not found in token")
}
if name == "" {
name = strings.Split(email, "@")[0]
}
user, err := s.upsertUser(ctx, email, name, subject)
if err != nil {
return SessionResponse{}, err
}
token, err := SignToken(s.jwtSecret, Claims{
UserID: user.ID,
Email: user.Email,
Exp: time.Now().Add(7 * 24 * time.Hour).Unix(),
})
if err != nil {
return SessionResponse{}, err
}
return SessionResponse{Token: token, User: user}, nil
}
func (s *Service) upsertUser(ctx context.Context, email, displayName, subject string) (User, error) {
var user User
err := s.pool.QueryRow(ctx, `
INSERT INTO users (email, display_name, provider, provider_subject)
VALUES ($1, $2, 'google', $3)
ON CONFLICT (email) DO UPDATE SET
display_name = EXCLUDED.display_name,
provider_subject = EXCLUDED.provider_subject
RETURNING id, email, display_name
`, email, displayName, subject).Scan(&user.ID, &user.Email, &user.DisplayName)
if err != nil {
return User{}, fmt.Errorf("upsert user: %w", err)
}
return user, nil
}
func SignToken(secret string, claims Claims) (string, error) {
body, err := json.Marshal(claims)
if err != nil {
return "", err
}
payload := base64.RawURLEncoding.EncodeToString(body)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return payload + "." + sig, nil
}
func VerifyToken(secret, token string) (Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return Claims{}, errors.New("invalid token")
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(parts[0]))
expected := mac.Sum(nil)
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(expected, actual) {
return Claims{}, errors.New("invalid signature")
}
body, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return Claims{}, err
}
var claims Claims
if err := json.Unmarshal(body, &claims); err != nil {
return Claims{}, err
}
if claims.Exp < time.Now().Unix() {
return Claims{}, fmt.Errorf("token expired")
}
return claims, nil
}

24
internal/auth/types.go Normal file
View File

@@ -0,0 +1,24 @@
package auth
import "github.com/google/uuid"
type GoogleLoginRequest struct {
IDToken string `json:"id_token"`
}
type SessionResponse struct {
Token string `json:"token"`
User User `json:"user"`
}
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
}
type Claims struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
Exp int64 `json:"exp"`
}

View File

@@ -19,6 +19,8 @@ type Config struct {
ModelKey string
ImageModelKey string
ThirdOneBin string
GoogleClientID string
JWTSecret string
}
func LoadFromEnv() Config {
@@ -30,6 +32,8 @@ func LoadFromEnv() Config {
ModelKey: envOrDefault("TUTOR_MODEL_KEY", defaultModelKey),
ImageModelKey: envOrDefault("TUTOR_IMAGE_MODEL_KEY", defaultImageModelKey),
ThirdOneBin: envOrDefault("THIRDONE_BIN", defaultThirdOneBin),
GoogleClientID: envOrDefault("GOOGLE_CLIENT_ID", ""),
JWTSecret: envOrDefault("JWT_SECRET", ""),
}
}

View File

@@ -2,19 +2,38 @@ package db
import (
"context"
_ "embed"
"embed"
"fmt"
"path"
"sort"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/001_init.sql
var initSQL string
//go:embed migrations/*.sql
var migrationsFS embed.FS
func Migrate(pool *pgxpool.Pool) error {
_, err := pool.Exec(context.Background(), initSQL)
files, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("run migration: %w", err)
return fmt.Errorf("read migrations dir: %w", err)
}
sort.Slice(files, func(i, j int) bool {
return files[i].Name() < files[j].Name()
})
for _, f := range files {
if path.Ext(f.Name()) != ".sql" {
continue
}
data, err := migrationsFS.ReadFile("migrations/" + f.Name())
if err != nil {
return fmt.Errorf("read migration %s: %w", f.Name(), err)
}
if _, err := pool.Exec(context.Background(), string(data)); err != nil {
return fmt.Errorf("run migration %s: %w", f.Name(), err)
}
}
return nil
}

View File

@@ -0,0 +1,10 @@
-- Auth: users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'google',
provider_subject TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -25,6 +25,9 @@ const els = {
status: document.querySelector("#status-line"),
error: document.querySelector("#error-line"),
title: document.querySelector("#session-title"),
gSignIn: document.querySelector(".g_id_signin"),
userInfo: document.querySelector("#user-info"),
logoutButton: document.querySelector("#logout-button"),
};
const readinessClassMap = {
@@ -359,11 +362,48 @@ function evidenceBlock(evidence = []) {
return `<section><h2>Evidence</h2><ul class="small-list">${evidence.map((item) => `<li>${escapeHTML(item.quote || item.id)}</li>`).join("")}</ul></section>`;
}
async function request(url, options = {}) {
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
...options,
window.handleCredentialResponse = async (response) => {
try {
const res = await request("/api/v1/auth/google", {
method: "POST",
body: JSON.stringify({ id_token: response.credential }),
});
localStorage.setItem("tutor_token", res.token);
localStorage.setItem("tutor_user", JSON.stringify(res.user));
setStatus(`Signed in as ${res.user.email}`);
renderAuth();
} catch (err) {
showError(err.message);
}
};
function renderAuth() {
const user = JSON.parse(localStorage.getItem("tutor_user") || "null");
const token = localStorage.getItem("tutor_token");
if (user && token) {
els.gSignIn.style.display = "none";
els.userInfo.style.display = "block";
els.userInfo.textContent = user.email;
els.logoutButton.style.display = "inline-block";
} else {
els.gSignIn.style.display = "block";
els.userInfo.style.display = "none";
els.logoutButton.style.display = "none";
}
}
els.logoutButton.addEventListener("click", () => {
localStorage.removeItem("tutor_token");
localStorage.removeItem("tutor_user");
renderAuth();
setStatus("Signed out");
});
async function request(url, options = {}) {
const token = localStorage.getItem("tutor_token");
const headers = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
const response = await fetch(url, { headers, ...options });
const body = await response.json();
if (!response.ok) {
throw new Error(body.error || `Request failed: ${response.status}`);
@@ -398,3 +438,5 @@ function escapeHTML(value) {
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
renderAuth();

View File

@@ -7,12 +7,20 @@
<link rel="stylesheet" href="/assets/styles.css" />
</head>
<body>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<main class="workspace">
<aside class="setup-pane" aria-label="Diagnostic setup">
<p class="eyebrow">Tutor Platform</p>
<h1>Interview practice</h1>
<p class="lede">Start a focused backend interview loop and turn one answer into evidence.</p>
<div id="auth-area" class="auth-area">
<div id="g_id_onload" data-client_id="13671390758-bp1ed6psn43bl86r8a9kv81o40nkea90.apps.googleusercontent.com" data-callback="handleCredentialResponse"></div>
<div class="g_id_signin" data-type="standard"></div>
<div id="user-info" class="user-info" style="display:none;"></div>
<button id="logout-button" class="small-button" type="button" style="display:none;">Sign out</button>
</div>
<form id="session-form" class="stacked-form">
<label>
User ID