Add host-first environment contracts (Local vs CI vs Prod), deps-only compose, and the Profile → Portrait → deep-access mock payment slice with device identity and auto-migrate on API startup. Co-authored-by: Cursor <cursoragent@cursor.com>
97 lines
2.2 KiB
Go
97 lines
2.2 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
|
)
|
|
|
|
type ctxKey string
|
|
|
|
const UserIDKey ctxKey = "user_id"
|
|
const DeviceKeyHeader = "X-Device-Key"
|
|
|
|
// DeviceAuth resolves or creates a Visitor→User via device key.
|
|
func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
key := c.GetHeader(DeviceKeyHeader)
|
|
if key == "" {
|
|
key = newDeviceKey()
|
|
c.Header(DeviceKeyHeader, key)
|
|
}
|
|
userID, err := ensureUser(c.Request.Context(), pool, key)
|
|
if err != nil {
|
|
response.Fail(c, http.StatusInternalServerError, 50001, "identity unavailable")
|
|
c.Abort()
|
|
return
|
|
}
|
|
c.Set(string(UserIDKey), userID.String())
|
|
c.Header(DeviceKeyHeader, key)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// UserIDFromContext returns the authenticated user id.
|
|
func UserIDFromContext(c *gin.Context) (uuid.UUID, bool) {
|
|
v, ok := c.Get(string(UserIDKey))
|
|
if !ok {
|
|
return uuid.Nil, false
|
|
}
|
|
id, err := uuid.Parse(v.(string))
|
|
return id, err == nil
|
|
}
|
|
|
|
func ensureUser(ctx context.Context, pool *pgxpool.Pool, deviceKey string) (uuid.UUID, error) {
|
|
var userID *uuid.UUID
|
|
err := pool.QueryRow(ctx, `
|
|
SELECT user_id FROM device_identities
|
|
WHERE device_key=$1 AND deleted_at IS NULL`, deviceKey,
|
|
).Scan(&userID)
|
|
if err == nil && userID != nil {
|
|
return *userID, nil
|
|
}
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return uuid.Nil, err
|
|
}
|
|
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var uid uuid.UUID
|
|
if err := tx.QueryRow(ctx,
|
|
`INSERT INTO users DEFAULT VALUES RETURNING id`,
|
|
).Scan(&uid); err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO device_identities(device_key, user_id)
|
|
VALUES ($1,$2)
|
|
ON CONFLICT (device_key) DO UPDATE SET user_id=EXCLUDED.user_id, updated_at=now()`,
|
|
deviceKey, uid,
|
|
); err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
return uid, nil
|
|
}
|
|
|
|
func newDeviceKey() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
return "dev_" + hex.EncodeToString(b)
|
|
}
|