99 lines
3.2 KiB
Go
99 lines
3.2 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
IsAdmin bool `json:"isAdmin"`
|
|
}
|
|
|
|
// UpsertUser finds or creates the account behind an email address. Requesting
|
|
// a magic link is what creates an account; there is no separate signup.
|
|
func (s *Store) UpsertUser(ctx context.Context, email string) (*User, error) {
|
|
var u User
|
|
err := s.pool.QueryRow(ctx, `
|
|
INSERT INTO "User" ("id","email","emailVerified")
|
|
VALUES ($1,$2,true)
|
|
ON CONFLICT ("email") DO UPDATE
|
|
SET "emailVerified" = true, "updatedAt" = now()
|
|
RETURNING "id","email","name","isAdmin"`,
|
|
NewID(), email).Scan(&u.ID, &u.Email, &u.Name, &u.IsAdmin)
|
|
return &u, err
|
|
}
|
|
|
|
// CreateVerification stores a hashed magic-link token. The plaintext never
|
|
// reaches the database, so a dump cannot be replayed into a login.
|
|
func (s *Store) CreateVerification(ctx context.Context, identifier, hashed string, expiresAt time.Time) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO "Verification" ("id","identifier","value","expiresAt")
|
|
VALUES ($1,$2,$3,$4)`, NewID(), identifier, hashed, expiresAt)
|
|
return err
|
|
}
|
|
|
|
// ConsumeVerification redeems a token exactly once. The single UPDATE is what
|
|
// makes it single-use: a second request finds consumedAt already set and gets
|
|
// no rows back, even if both arrive at the same instant.
|
|
func (s *Store) ConsumeVerification(ctx context.Context, hashed string) (string, error) {
|
|
var identifier string
|
|
err := s.pool.QueryRow(ctx, `
|
|
UPDATE "Verification" SET "consumedAt" = now(), "updatedAt" = now()
|
|
WHERE "value" = $1 AND "consumedAt" IS NULL AND "expiresAt" > now()
|
|
RETURNING "identifier"`, hashed).Scan(&identifier)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", ErrNotFound
|
|
}
|
|
return identifier, err
|
|
}
|
|
|
|
func (s *Store) CreateSession(ctx context.Context, userID, hashed string, expiresAt time.Time, ip, ua string) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO "Session" ("id","token","expiresAt","userId","ipAddress","userAgent")
|
|
VALUES ($1,$2,$3,$4,$5,$6)`,
|
|
NewID(), hashed, expiresAt, userID, nullable(ip), nullable(ua))
|
|
return err
|
|
}
|
|
|
|
func (s *Store) UserBySessionToken(ctx context.Context, hashed string) (*User, error) {
|
|
var u User
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT u."id", u."email", u."name", u."isAdmin"
|
|
FROM "Session" s JOIN "User" u ON u."id" = s."userId"
|
|
WHERE s."token" = $1 AND s."expiresAt" > now()`, hashed).Scan(
|
|
&u.ID, &u.Email, &u.Name, &u.IsAdmin)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return &u, err
|
|
}
|
|
|
|
func (s *Store) DeleteSession(ctx context.Context, hashed string) error {
|
|
_, err := s.pool.Exec(ctx, `DELETE FROM "Session" WHERE "token" = $1`, hashed)
|
|
return err
|
|
}
|
|
|
|
// PurgeExpiredAuth keeps the two token tables from growing without bound.
|
|
// Expired rows are already refused on read, so this is housekeeping only.
|
|
func (s *Store) PurgeExpiredAuth(ctx context.Context) error {
|
|
if _, err := s.pool.Exec(ctx, `DELETE FROM "Session" WHERE "expiresAt" < now()`); err != nil {
|
|
return err
|
|
}
|
|
_, err := s.pool.Exec(ctx,
|
|
`DELETE FROM "Verification" WHERE "expiresAt" < now() - interval '1 day'`)
|
|
return err
|
|
}
|
|
|
|
func nullable(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|