Author SHA1 Message Date
Krispire Admin aecdd1030a feat: add user centric features 2026-05-30 23:43:27 +05:30
Krispire Admin 97422c1ef5 add auth 2026-05-30 02:33:57 +05:30
36 changed files with 3726 additions and 120 deletions
+23 -11
View File
@@ -9,19 +9,28 @@ MONGO_DB=spsays
# PostgreSQL (users, relations)
POSTGRES_URI=postgres://user:pass@localhost:5432/spsays?sslmode=disable
# Meilisearch
MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_API_KEY=
# Typesense
TYPESENSE_URL=https://search.prabhupadasays.org
TYPESENSE_API_KEY=
# DragonflyDB / Redis
REDIS_URI=redis://localhost:6379
# S3
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_ENDPOINT=
# Cloudflare R2 (avatar uploads via presigned URLs)
# R2 endpoint is auto-built as https://<account_id>.r2.cloudflarestorage.com
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=
# Public base URL where bucket objects are served (custom domain or pub-XXX.r2.dev)
R2_PUBLIC_URL=
# SMTP (password-reset + email-verification)
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM="Prabhupāda Says <noreply@prabhupadasays.org>"
# Push Notifications
FCM_CREDENTIALS_FILE=
@@ -32,7 +41,10 @@ APNS_BUNDLE_ID=
# JWT
JWT_SECRET=change-me-in-production
JWT_EXPIRATION=24h
JWT_EXPIRATION=168h
# Cookie domain — empty in dev (uses request host), ".prabhupadasays.org" in prod
COOKIE_DOMAIN=
COOKIE_SECURE=false
# Frontend
# Frontend (CORS + email link base)
FRONTEND_URL=http://localhost:3000
+22 -3
View File
@@ -15,7 +15,26 @@ require (
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect
github.com/aws/smithy-go v1.26.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -30,7 +49,7 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
+45
View File
@@ -1,6 +1,42 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
@@ -15,6 +51,8 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -81,6 +119,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -89,6 +129,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -96,6 +138,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -104,6 +147,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+56 -19
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strconv"
"time"
"github.com/joho/godotenv"
)
@@ -28,33 +29,46 @@ type Config struct {
// DragonflyDB / Redis
RedisURI string
// AWS S3
S3Region string
S3Bucket string
S3AccessKey string
S3SecretKey string
S3Endpoint string // optional — for S3-compatible services (MinIO, etc.)
// Cloudflare R2 (S3-compatible) — for avatar uploads via presigned URLs.
R2AccountID string
R2AccessKeyID string
R2SecretAccessKey string
R2Bucket string
R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL
// Push Notifications
FCMCredentialsFile string // path to Firebase service account JSON
APNsKeyFile string // path to APNs auth key
// SMTP — used for password-reset and email-verification mails.
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPFrom string // "Prabhupāda Says <noreply@prabhupadasays.org>"
// Push Notifications (existing — unchanged).
FCMCredentialsFile string
APNsKeyFile string
APNsKeyID string
APNsTeamID string
APNsBundleID string
// JWT Auth
JWTSecret string
JWTExpiration string // e.g., "24h"
JWTExpiration time.Duration // parsed at load time
CookieDomain string // empty in dev; ".prabhupadasays.org" in prod
CookieSecure bool // true in production
// Frontend
FrontendURL string // for CORS
FrontendURL string // for CORS + reset/verify links in emails
}
// Load reads configuration from environment variables (with .env fallback).
func Load() (*Config, error) {
// Load .env file if present (ignored in production)
_ = godotenv.Load()
jwtExp, err := time.ParseDuration(getEnv("JWT_EXPIRATION", "168h"))
if err != nil {
return nil, fmt.Errorf("invalid JWT_EXPIRATION: %w", err)
}
cfg := &Config{
Env: getEnv("APP_ENV", "development"),
Port: getEnvInt("PORT", 8080),
@@ -69,11 +83,17 @@ func Load() (*Config, error) {
RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"),
S3Region: getEnv("S3_REGION", ""),
S3Bucket: getEnv("S3_BUCKET", ""),
S3AccessKey: getEnv("S3_ACCESS_KEY", ""),
S3SecretKey: getEnv("S3_SECRET_KEY", ""),
S3Endpoint: getEnv("S3_ENDPOINT", ""),
R2AccountID: getEnv("R2_ACCOUNT_ID", ""),
R2AccessKeyID: getEnv("R2_ACCESS_KEY_ID", ""),
R2SecretAccessKey: getEnv("R2_SECRET_ACCESS_KEY", ""),
R2Bucket: getEnv("R2_BUCKET", ""),
R2PublicURL: getEnv("R2_PUBLIC_URL", ""),
SMTPHost: getEnv("SMTP_HOST", ""),
SMTPPort: getEnvInt("SMTP_PORT", 587),
SMTPUsername: getEnv("SMTP_USERNAME", ""),
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"),
FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""),
APNsKeyFile: getEnv("APNS_KEY_FILE", ""),
@@ -82,15 +102,23 @@ func Load() (*Config, error) {
APNsBundleID: getEnv("APNS_BUNDLE_ID", ""),
JWTSecret: getEnv("JWT_SECRET", ""),
JWTExpiration: getEnv("JWT_EXPIRATION", "24h"),
JWTExpiration: jwtExp,
CookieDomain: getEnv("COOKIE_DOMAIN", ""),
CookieSecure: getEnvBool("COOKIE_SECURE", false),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
}
// Validate required fields
if cfg.MongoURI == "" {
return nil, fmt.Errorf("MONGO_URI is required")
}
if cfg.Env == "production" && cfg.JWTSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required in production")
}
if cfg.JWTSecret == "" {
// Dev fallback so the server can boot without secrets configured.
cfg.JWTSecret = "dev-only-jwt-secret-do-not-use-in-production"
}
return cfg, nil
}
@@ -110,3 +138,12 @@ func getEnvInt(key string, fallback int) int {
}
return fallback
}
func getEnvBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}
+221
View File
@@ -0,0 +1,221 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/response"
"github.com/prabhupadasays/spsays-bff/pkg/validator"
)
// AuthHandler exposes the auth-related HTTP endpoints.
//
// Every handler is a 5-line shim: parse → validate → service → render.
// All branching (already exists? wrong password? expired link?) lives in
// AuthService and surfaces here only as sentinel errors mapped to status
// codes via mapAuthErr.
type AuthHandler struct {
svc *service.AuthService
cookies auth.CookieOptions
}
func NewAuthHandler(svc *service.AuthService, cookies auth.CookieOptions) *AuthHandler {
return &AuthHandler{svc: svc, cookies: cookies}
}
// --- request shapes ---
type signUpReq struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
type logInReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
type forgotReq struct {
Email string `json:"email"`
}
type resetReq struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
type verifyReq struct {
Token string `json:"token"`
}
// --- handlers ---
// SignUp creates a user, plants the session cookie, returns the user.
func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
var req signUpReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "email", req.Email)
validator.Email(v, "email", req.Email)
validator.Required(v, "password", req.Password)
validator.MinLength(v, "password", req.Password, 8)
validator.Required(v, "name", req.Name)
validator.MaxLength(v, "name", req.Name, 120)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
res, err := h.svc.SignUp(r.Context(), req.Email, req.Password, req.Name)
if err != nil {
h.renderAuthErr(w, err)
return
}
auth.Set(w, res.Token, res.Expires, h.cookies)
response.JSON(w, http.StatusCreated, res.User)
}
// LogIn authenticates an existing user.
func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
var req logInReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "email", req.Email)
validator.Required(v, "password", req.Password)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
res, err := h.svc.LogIn(r.Context(), req.Email, req.Password)
if err != nil {
h.renderAuthErr(w, err)
return
}
auth.Set(w, res.Token, res.Expires, h.cookies)
response.JSON(w, http.StatusOK, res.User)
}
// LogOut just clears the cookie. JWTs remain technically valid until expiry,
// but with the cookie gone the browser can't present them.
func (h *AuthHandler) LogOut(w http.ResponseWriter, r *http.Request) {
auth.Clear(w, h.cookies)
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// Me returns the authenticated user. Protected by middleware.RequireAuth.
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
tv := middleware.TokenVersion(r.Context())
u, err := h.svc.Me(r.Context(), uid, tv)
if err != nil {
response.Error(w, http.StatusUnauthorized, "not authenticated")
return
}
response.JSON(w, http.StatusOK, u)
}
// ForgotPassword emails a reset link if the address exists. We always return
// 200 to avoid revealing whether the email is registered.
func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
var req forgotReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "email", req.Email)
validator.Email(v, "email", req.Email)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
if err := h.svc.RequestPasswordReset(r.Context(), req.Email); err != nil {
response.Error(w, http.StatusInternalServerError, "could not process request")
return
}
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ResetPassword consumes a reset token and sets a new password.
func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
var req resetReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "token", req.Token)
validator.Required(v, "new_password", req.NewPassword)
validator.MinLength(v, "new_password", req.NewPassword, 8)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
if err := h.svc.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil {
h.renderAuthErr(w, err)
return
}
// Force the user to log in again after a reset (their token_version is now stale).
auth.Clear(w, h.cookies)
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// VerifyEmail flips email_verified_at after the user clicks the link.
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
var req verifyReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.Token == "" {
response.Error(w, http.StatusBadRequest, "token is required")
return
}
if err := h.svc.VerifyEmail(r.Context(), req.Token); err != nil {
h.renderAuthErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ResendVerification — protected — refires the verification email for the
// currently-logged-in user.
func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.ResendVerification(r.Context(), uid); err != nil {
h.renderAuthErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// --- error mapping ---
func (h *AuthHandler) renderAuthErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, service.ErrInvalidCredentials),
errors.Is(err, service.ErrInvalidOrExpiredLink):
response.Error(w, http.StatusUnauthorized, err.Error())
case errors.Is(err, service.ErrEmailAlreadyTaken):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, service.ErrEmailAlreadyVerified),
errors.Is(err, service.ErrWeakPassword):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrUserNotFound):
response.Error(w, http.StatusNotFound, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "something went wrong")
}
}
+44 -10
View File
@@ -147,6 +147,12 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
limit = 100
}
// `exact=1` (or "true") disables typo tolerance + prefix expansion so the
// query only matches literal keywords. Stopword filtering still applies —
// "letter to mukunda" with exact=1 still drops "letter"/"to" so the search
// is effectively the literal word "mukunda" with no fuzzy variants.
exact := parseBoolParam(r.URL.Query().Get("exact"))
ctx := r.Context()
var results []searchResultItem
@@ -154,13 +160,13 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
if searchType == "all" {
// Search all 9 collections
results, totalFound = h.multiSearch(ctx, nil, query, page, limit)
results, totalFound = h.multiSearch(ctx, nil, query, page, limit, exact)
} else if len(requestedTypes) == 1 {
// Single collection — use singleSearch for efficiency
results, totalFound = h.singleSearch(ctx, requestedTypes[0], query, page, limit)
results, totalFound = h.singleSearch(ctx, requestedTypes[0], query, page, limit, exact)
} else {
// Multiple collections — use multiSearch with a subset
results, totalFound = h.multiSearch(ctx, requestedTypes, query, page, limit)
results, totalFound = h.multiSearch(ctx, requestedTypes, query, page, limit, exact)
}
totalPages := int(math.Ceil(float64(totalFound) / float64(limit)))
@@ -181,8 +187,10 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
// multiSearch searches across all 9 collections using Typesense multi_search,
// merges results by text_match score, and returns a single paginated list.
// multiSearch searches across multiple collections using Typesense multi_search.
// If collections is nil, searches all 9 collections.
func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int) ([]searchResultItem, int) {
// If collections is nil, searches all 9 collections. When `exact` is true,
// typo tolerance and prefix expansion are turned off so only literal-keyword
// matches come back.
func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int, exact bool) ([]searchResultItem, int) {
if collections == nil {
collections = []string{
"verses", "prose", "letters", "transcriptions", "glossary",
@@ -193,6 +201,7 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
exhaustive := true
prioritizeExact := true
prioritizeTokenPos := true
prefix, numTypos := fuzzinessKnobs(exact)
searches := make([]api.MultiSearchCollectionParameters, 0, len(collections))
for _, col := range collections {
queryBy := collectionQueryByFields[col]
@@ -205,8 +214,8 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
QueryBy: stringPtr(queryBy),
PerPage: &perPage,
Page: &pg,
Prefix: stringPtr("true"),
NumTypos: stringPtr("2"),
Prefix: stringPtr(prefix),
NumTypos: stringPtr(numTypos),
ExhaustiveSearch: &exhaustive,
Stopwords: stringPtr(searchStopwordsSet),
// Boost docs whose tokens are exact matches and appear early in the field
@@ -264,7 +273,7 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
}
// singleSearch searches a single collection.
func (h *SearchHandler) singleSearch(ctx context.Context, collection, query string, page, limit int) ([]searchResultItem, int) {
func (h *SearchHandler) singleSearch(ctx context.Context, collection, query string, page, limit int, exact bool) ([]searchResultItem, int) {
queryBy, ok := collectionQueryByFields[collection]
if !ok {
return []searchResultItem{}, 0
@@ -273,13 +282,14 @@ func (h *SearchHandler) singleSearch(ctx context.Context, collection, query stri
exhaustive := true
prioritizeExact := true
prioritizeTokenPos := true
prefix, numTypos := fuzzinessKnobs(exact)
params := &api.SearchCollectionParams{
Q: stringPtr(query),
QueryBy: stringPtr(queryBy),
Page: &page,
PerPage: &limit,
Prefix: stringPtr("true"),
NumTypos: stringPtr("2"),
Prefix: stringPtr(prefix),
NumTypos: stringPtr(numTypos),
ExhaustiveSearch: &exhaustive,
Stopwords: stringPtr(searchStopwordsSet),
// Boost docs whose tokens are exact matches and appear early in the field
@@ -534,3 +544,27 @@ func parseIntParam(s string, defaultVal int) int {
func stringPtr(s string) *string {
return &s
}
// parseBoolParam returns true for "1", "true", "yes", "on" (case-insensitive).
// Everything else (including the empty string) is false.
func parseBoolParam(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "yes", "on":
return true
}
return false
}
// fuzzinessKnobs maps the high-level `exact` flag to the two Typesense params
// that actually disable fuzzy matching:
//
// exact=false → prefix=true, num_typos=2 (default forgiving search)
// exact=true → prefix=false, num_typos=0 (literal-keyword only)
//
// Stopword filtering is left unchanged in both modes.
func fuzzinessKnobs(exact bool) (prefix, numTypos string) {
if exact {
return "false", "0"
}
return "true", "2"
}
+508
View File
@@ -0,0 +1,508 @@
// Package handler — social endpoints for posts, bookmarks, collections, and
// public profiles. Three small handler structs share this file because each
// is itself just a thin shim over its service. mapSocialErr at the bottom
// centralizes the error→HTTP-status mapping so every endpoint surfaces the
// same shapes for plan-limit / forbidden / not-found.
package handler
import (
"encoding/json"
"errors"
"math"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/response"
"github.com/prabhupadasays/spsays-bff/pkg/validator"
)
// ---------------------------------------------------------------------------
// PostHandler
// ---------------------------------------------------------------------------
type PostHandler struct{ svc *service.PostService }
func NewPostHandler(svc *service.PostService) *PostHandler { return &PostHandler{svc: svc} }
type createPostReq struct {
Caption string `json:"caption"`
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
ImageURLs []string `json:"image_urls"`
}
func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req createPostReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "caption", req.Caption)
validator.Required(v, "source_url", req.SourceURL)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
p, err := h.svc.Create(r.Context(), uid, req.Caption, req.SourceURL, req.SourceTitle, req.ImageURLs)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusCreated, p)
}
func (h *PostHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
p, err := h.svc.Get(r.Context(), id)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, p)
}
func (h *PostHandler) Delete(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id"), uid); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
posts, total, err := h.svc.ListMine(r.Context(), uid, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
type replaceImagesReq struct {
ImageURLs []string `json:"image_urls"`
}
// ReplaceImages swaps the post's image set. Body: {"image_urls": [...]}.
// An empty array removes all images (used for the "remove background" flow).
// Owner-only; non-owners and missing posts both 404 to avoid leaking IDs.
func (h *PostHandler) ReplaceImages(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
var req replaceImagesReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
// Defensive cap — posts in this app only display one background, but
// the schema supports many. Cap at 5 to match the compose-post limit.
if len(req.ImageURLs) > 5 {
response.Error(w, http.StatusBadRequest, "at most 5 images")
return
}
p, err := h.svc.ReplaceImages(r.Context(), id, uid, req.ImageURLs)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, p)
}
type imagePresignReq struct {
ContentType string `json:"content_type"`
}
func (h *PostHandler) ImagePresign(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req imagePresignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, public, err := h.svc.ImagePresign(r.Context(), uid, strings.TrimSpace(req.ContentType))
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]string{
"upload_url": upload,
"public_url": public,
})
}
// ---------------------------------------------------------------------------
// BookmarkHandler
// ---------------------------------------------------------------------------
type BookmarkHandler struct{ svc *service.BookmarkService }
func NewBookmarkHandler(svc *service.BookmarkService) *BookmarkHandler {
return &BookmarkHandler{svc: svc}
}
type createBookmarkReq struct {
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
Excerpt *string `json:"excerpt"`
Note *string `json:"note"`
}
func (h *BookmarkHandler) Create(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req createBookmarkReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if strings.TrimSpace(req.SourceURL) == "" {
response.Error(w, http.StatusBadRequest, "source_url is required")
return
}
b, err := h.svc.Create(r.Context(), uid, req.SourceURL, req.SourceTitle, req.Excerpt, req.Note)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusCreated, b)
}
func (h *BookmarkHandler) List(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
out, err := h.svc.List(r.Context(), uid)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, out)
}
func (h *BookmarkHandler) Delete(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id"), uid); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// ---------------------------------------------------------------------------
// CollectionHandler
// ---------------------------------------------------------------------------
type CollectionHandler struct {
svc *service.CollectionService
postRepo *repository.PostRepo
auth *service.AuthService // unused for now; placeholder for follow/mute later
issuer *auth.Issuer // used to detect viewer id on optionally-authed routes
users *repository.UserRepo
}
func NewCollectionHandler(svc *service.CollectionService, posts *repository.PostRepo, issuer *auth.Issuer, users *repository.UserRepo) *CollectionHandler {
return &CollectionHandler{svc: svc, postRepo: posts, issuer: issuer, users: users}
}
type createCollectionReq struct {
Title string `json:"title"`
Description *string `json:"description"`
CoverURL *string `json:"cover_url"`
IsPublic bool `json:"is_public"`
}
func (h *CollectionHandler) Create(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req createCollectionReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
c, err := h.svc.Create(r.Context(), uid, req.Title, req.Description, req.CoverURL, req.IsPublic)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusCreated, c)
}
type updateCollectionReq struct {
Title *string `json:"title"`
Description *string `json:"description"`
CoverURL *string `json:"cover_url"`
IsPublic *bool `json:"is_public"`
}
func (h *CollectionHandler) Update(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
var req updateCollectionReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
c, err := h.svc.Update(r.Context(), id, uid, req.Title, req.Description, req.CoverURL, req.IsPublic)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, c)
}
func (h *CollectionHandler) Delete(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id"), uid); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
func (h *CollectionHandler) ListMine(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
out, err := h.svc.ListMine(r.Context(), uid)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, out)
}
// Get is public-or-private depending on the collection's `is_public` flag.
// Anonymous viewers can read public collections; private ones require the
// owner to be logged in. We try to read the JWT cookie best-effort to
// detect the viewer's identity.
func (h *CollectionHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
viewerID := optionalUserID(r, h.issuer, h.users)
c, posts, err := h.svc.Get(r.Context(), id, viewerID)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]any{
"collection": c,
"posts": posts,
})
}
type collectionItemReq struct {
PostID string `json:"post_id"`
}
func (h *CollectionHandler) AddItem(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
collectionID := chi.URLParam(r, "id")
var req collectionItemReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.AddItem(r.Context(), collectionID, req.PostID, uid); err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (h *CollectionHandler) RemoveItem(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
collectionID := chi.URLParam(r, "id")
postID := chi.URLParam(r, "post_id")
if err := h.svc.RemoveItem(r.Context(), collectionID, postID, uid); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
type reorderItemsReq struct {
PostIDs []string `json:"post_ids"`
}
// ReorderItems takes an ordered list of post ids and rewrites their
// positions in the collection. Owner-only. Body: {"post_ids": [...]}.
func (h *CollectionHandler) ReorderItems(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
collectionID := chi.URLParam(r, "id")
var req reorderItemsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetItemOrder(r.Context(), collectionID, uid, req.PostIDs); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// ContainingPost answers "which of my collections already contain post
// X?" — used by the save-to-collection drawer so a post can't be added
// twice. Response: {"collection_ids": ["uuid", ...]}.
func (h *CollectionHandler) ContainingPost(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
postID := chi.URLParam(r, "post_id")
ids, err := h.svc.CollectionsContaining(r.Context(), postID, uid)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string][]string{
"collection_ids": ids,
})
}
func (h *CollectionHandler) CoverPresign(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req imagePresignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, public, err := h.svc.CoverPresign(r.Context(), uid, strings.TrimSpace(req.ContentType))
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]string{
"upload_url": upload,
"public_url": public,
})
}
// ---------------------------------------------------------------------------
// ProfileHandler — public /api/v1/u/{handle}
// ---------------------------------------------------------------------------
type ProfileHandler struct {
users *repository.UserRepo
posts *repository.PostRepo
collections *repository.CollectionRepo
}
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo) *ProfileHandler {
return &ProfileHandler{users: users, posts: posts, collections: collections}
}
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
u, err := h.users.FindByHandle(r.Context(), handle)
if err != nil {
mapSocialErr(w, err)
return
}
count, err := h.posts.CountByUser(r.Context(), u.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load profile")
return
}
cols, err := h.collections.ListPublicByUser(r.Context(), u.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load collections")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"handle": u.Handle,
"name": u.Name,
"avatar_url": u.AvatarURL,
"joined_at": u.CreatedAt,
"post_count": count,
"public_collections": cols,
})
}
// Feed returns posts for the public feed. Same payload as ListMine but
// looked up by handle so it's reachable anonymously.
func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
limit, offset := parseLimitOffset(r)
posts, total, err := h.posts.ListByHandle(r.Context(), handle, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
// ---------------------------------------------------------------------------
// shared helpers
// ---------------------------------------------------------------------------
func mapSocialErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrUserNotFound),
errors.Is(err, repository.ErrPostNotFound),
errors.Is(err, repository.ErrBookmarkNotFound),
errors.Is(err, repository.ErrCollectionNotFound),
errors.Is(err, service.ErrNotFound):
response.Error(w, http.StatusNotFound, "not found")
case errors.Is(err, service.ErrForbidden):
response.Error(w, http.StatusForbidden, "not allowed")
case errors.Is(err, service.ErrPlanLimit):
response.Error(w, http.StatusPaymentRequired, err.Error())
default:
response.Error(w, http.StatusBadRequest, err.Error())
}
}
func parseLimitOffset(r *http.Request) (limit, offset int) {
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
offset = n
}
}
if limit <= 0 || limit > 50 {
limit = 20
}
if offset < 0 {
offset = 0
}
return limit, offset
}
func paginationMeta(total, limit, offset int) *response.Meta {
if limit == 0 {
limit = 20
}
totalPages := int(math.Ceil(float64(total) / float64(limit)))
return &response.Meta{
Page: (offset / limit) + 1,
Limit: limit,
Total: int64(total),
TotalPages: totalPages,
}
}
// optionalUserID reads the auth cookie if present and verifies the JWT —
// used by routes that show different data to logged-in vs anonymous viewers
// (e.g. private collections). Returns "" on any failure (treated as anon).
func optionalUserID(r *http.Request, issuer *auth.Issuer, users *repository.UserRepo) string {
raw := auth.Read(r)
if raw == "" || issuer == nil {
return ""
}
claims, err := issuer.Parse(raw)
if err != nil {
return ""
}
if users != nil {
u, err := users.FindByID(r.Context(), claims.UserID)
if err != nil || u.TokenVersion != claims.TokenVersion {
return ""
}
}
return claims.UserID
}
+94
View File
@@ -0,0 +1,94 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// UserHandler exposes profile-edit + avatar-presign endpoints.
// All routes here sit behind middleware.RequireAuth.
type UserHandler struct {
svc *service.UserService
}
func NewUserHandler(svc *service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}
// updateProfileReq accepts partial updates. Use pointers so the JSON decoder
// can tell "client explicitly sent null" from "field not present" — the
// service treats nil-name as "leave alone" and ""-name as an error.
type updateProfileReq struct {
Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"`
Handle *string `json:"handle"`
}
// UpdateProfile patches name, avatar, and/or handle.
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req updateProfileReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
u, err := h.svc.UpdateProfile(r.Context(), uid, req.Name, req.AvatarURL, req.Handle)
if err != nil {
if errors.Is(err, repository.ErrHandleTaken) {
response.Error(w, http.StatusConflict, "that handle is already in use")
return
}
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusOK, u)
}
type presignReq struct {
ContentType string `json:"content_type"`
}
type presignResp struct {
UploadURL string `json:"upload_url"`
PublicURL string `json:"public_url"`
}
// AvatarPresign returns a one-shot R2 PUT URL the browser uses to upload.
// The frontend then PATCHes /users/me with the returned public_url to
// persist the new avatar on the user row.
func (h *UserHandler) AvatarPresign(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req presignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
ct := strings.TrimSpace(req.ContentType)
if ct == "" {
response.Error(w, http.StatusBadRequest, "content_type is required")
return
}
upload, public, err := h.svc.AvatarPresign(r.Context(), uid, ct)
if err != nil {
// Differentiate "feature off" from "bad content type" so the frontend
// can show a useful message.
if strings.Contains(err.Error(), "not configured") {
response.Error(w, http.StatusServiceUnavailable, err.Error())
return
}
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusOK, presignResp{UploadURL: upload, PublicURL: public})
}
+48 -26
View File
@@ -2,56 +2,78 @@ package middleware
import (
"context"
"errors"
"net/http"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
type contextKey string
type ctxKey string
const UserIDKey contextKey = "user_id"
const (
userIDKey ctxKey = "user_id"
tokenVersionKey ctxKey = "tv"
)
// Auth is a placeholder middleware for JWT authentication.
// It extracts the Bearer token from the Authorization header,
// validates it, and sets the user ID in the request context.
func Auth(jwtSecret string) func(next http.Handler) http.Handler {
// RequireAuth is the middleware applied to every protected route.
//
// It pulls the JWT from the cookie, verifies signature + expiry, then asks
// the UserRepo for the current token_version and rejects if the JWT carries
// a stale version (password-change or forced logout invalidates older JWTs).
//
// On success it stuffs (user_id, token_version) into the request context;
// handlers read them via UserID() / TokenVersion().
func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
response.Error(w, http.StatusUnauthorized, "missing authorization header")
raw := auth.Read(r)
if raw == "" {
response.Error(w, http.StatusUnauthorized, "not authenticated")
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
response.Error(w, http.StatusUnauthorized, "invalid authorization format")
claims, err := issuer.Parse(raw)
if err != nil {
response.Error(w, http.StatusUnauthorized, "invalid or expired session")
return
}
token := parts[1]
// TODO: validate JWT token and extract user ID
_ = token
_ = jwtSecret
userID := "" // placeholder
if userID == "" {
response.Error(w, http.StatusUnauthorized, "invalid or expired token")
u, err := users.FindByID(r.Context(), claims.UserID)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
response.Error(w, http.StatusUnauthorized, "account no longer exists")
return
}
response.Error(w, http.StatusInternalServerError, "auth check failed")
return
}
if u.TokenVersion != claims.TokenVersion {
response.Error(w, http.StatusUnauthorized, "session expired — please log in again")
return
}
ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// GetUserID extracts the user ID from the request context.
func GetUserID(ctx context.Context) string {
if v, ok := ctx.Value(UserIDKey).(string); ok {
// UserID returns the authenticated user's id from the request context.
// Returns "" if RequireAuth has not run.
func UserID(ctx context.Context) string {
if v, ok := ctx.Value(userIDKey).(string); ok {
return v
}
return ""
}
// TokenVersion returns the JWT's token_version claim from context.
func TokenVersion(ctx context.Context) int {
if v, ok := ctx.Value(tokenVersionKey).(int); ok {
return v
}
return 0
}
+68
View File
@@ -0,0 +1,68 @@
package model
import "time"
// Post is a public quote on a user's feed. Caption is the verbatim selected
// text from the knowledgebase; SourceURL links back to the page the user
// was reading. Images are loaded separately via PostImage rows.
type Post struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Caption string `json:"caption"`
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
Images []PostImage `json:"images"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Author info — populated by repo joins for feed listings.
AuthorName string `json:"author_name,omitempty"`
AuthorHandle string `json:"author_handle,omitempty"`
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
}
// PostImage represents one attached image on a post.
type PostImage struct {
ID string `json:"id"`
URL string `json:"url"`
Position int `json:"position"`
}
// Bookmark is a private "save my place" marker.
type Bookmark struct {
ID string `json:"id"`
UserID string `json:"user_id"`
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
Excerpt *string `json:"excerpt,omitempty"`
Note *string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CollectionRecord is a user-curated group of posts.
//
// Renamed to avoid colliding with the legacy `Collection` model in user.go
// from when bookmarks/collections were sketched out earlier. Once that old
// model is unused we can drop it; for now this keeps both compiling.
type CollectionRecord struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
CoverURL *string `json:"cover_url,omitempty"`
IsPublic bool `json:"is_public"`
ItemCount int `json:"item_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PublicProfile is what /u/{handle} returns — the parts of a User safe to
// expose to anyone, plus aggregate counts and the user's posts.
type PublicProfile struct {
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
JoinedAt time.Time `json:"joined_at"`
PostCount int `json:"post_count"`
BookmarkCount int `json:"-"` // never serialized — private
}
+34 -40
View File
@@ -2,51 +2,45 @@ package model
import "time"
// User represents a user in PostgreSQL.
// User represents a row in the `users` table.
//
// PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling
// even if a caller accidentally passes a User through response.JSON.
//
// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null`
// when the user has not yet verified, instead of an omitted field — the
// frontend uses this to decide whether to show the "verify your email" ribbon.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Avatar *string `json:"avatar,omitempty"`
Role string `json:"role"` // "user" | "admin" | "moderator"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Email string `json:"email"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
Role string `json:"role"`
Plan string `json:"plan"` // "free" | "premium"
EmailVerifiedAt *time.Time `json:"email_verified_at"`
PasswordHash string `json:"-"`
TokenVersion int `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Bookmark represents a user's bookmarked content.
type Bookmark struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ContentType string `json:"content_type"` // "verse" | "letter" | "essay" | "transcription" | etc.
ContentID string `json:"content_id"`
ContentRef string `json:"content_ref"` // human-readable reference (e.g., "BG 1.1")
SelectedText *string `json:"selected_text,omitempty"`
Note *string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
// AuthToken represents a row in the `auth_tokens` table.
// Used for both password reset and email verification flows.
type AuthToken struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Purpose string `json:"purpose"` // "password_reset" | "email_verification"
TokenHash string `json:"-"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Collection represents a user-created collection.
type Collection struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
IsPublic bool `json:"is_public"`
ItemCount int `json:"item_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Mention represents a mention of content shared by a user.
type Mention struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ContentType string `json:"content_type"`
ContentID string `json:"content_id"`
ContentRef string `json:"content_ref"`
Text string `json:"text"`
CreatedAt time.Time `json:"created_at"`
}
const (
PurposePasswordReset = "password_reset"
PurposeEmailVerification = "email_verification"
)
// ShortURL represents a shortened URL.
type ShortURL struct {
+96
View File
@@ -0,0 +1,96 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var ErrBookmarkNotFound = errors.New("bookmark not found")
var ErrBookmarkExists = errors.New("bookmark already exists for this URL")
type BookmarkRepo struct{ pg *Postgres }
func NewBookmarkRepo(pg *Postgres) *BookmarkRepo { return &BookmarkRepo{pg: pg} }
// Create inserts a new bookmark. ErrBookmarkExists on (user_id, source_url) collision.
func (r *BookmarkRepo) Create(ctx context.Context, userID, sourceURL, sourceTitle string, excerpt, note *string) (*model.Bookmark, error) {
b := &model.Bookmark{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO bookmarks (user_id, source_url, source_title, excerpt, note)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at`,
userID, sourceURL, sourceTitle, excerpt, note,
).Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle, &b.Excerpt, &b.Note, &b.CreatedAt)
if err != nil {
if strings.Contains(err.Error(), "23505") {
return nil, ErrBookmarkExists
}
return nil, fmt.Errorf("create bookmark: %w", err)
}
return b, nil
}
// ListByUser returns all bookmarks for the caller, newest first.
func (r *BookmarkRepo) ListByUser(ctx context.Context, userID string) ([]model.Bookmark, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at
FROM bookmarks WHERE user_id = $1 ORDER BY created_at DESC`, userID)
if err != nil {
return nil, fmt.Errorf("query bookmarks: %w", err)
}
defer rows.Close()
out := []model.Bookmark{}
for rows.Next() {
var b model.Bookmark
if err := rows.Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle,
&b.Excerpt, &b.Note, &b.CreatedAt); err != nil {
return nil, fmt.Errorf("scan bookmark: %w", err)
}
out = append(out, b)
}
return out, rows.Err()
}
// CountByUser is used by the plan-limit enforcer.
func (r *BookmarkRepo) CountByUser(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM bookmarks WHERE user_id = $1`, userID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count bookmarks: %w", err)
}
return n, nil
}
// Delete removes a bookmark belonging to the caller.
func (r *BookmarkRepo) Delete(ctx context.Context, id, userID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM bookmarks WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete bookmark: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrBookmarkNotFound
}
return nil
}
// FindByID returns one bookmark (used for the owner-check path).
func (r *BookmarkRepo) FindByID(ctx context.Context, id string) (*model.Bookmark, error) {
b := &model.Bookmark{}
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at
FROM bookmarks WHERE id = $1`, id,
).Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle, &b.Excerpt, &b.Note, &b.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrBookmarkNotFound
}
return nil, fmt.Errorf("find bookmark: %w", err)
}
return b, nil
}
+263
View File
@@ -0,0 +1,263 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var ErrCollectionNotFound = errors.New("collection not found")
type CollectionRepo struct{ pg *Postgres }
func NewCollectionRepo(pg *Postgres) *CollectionRepo { return &CollectionRepo{pg: pg} }
// Create inserts a new collection owned by userID.
func (r *CollectionRepo) Create(ctx context.Context, userID, title string, description, coverURL *string, isPublic bool) (*model.CollectionRecord, error) {
c := &model.CollectionRecord{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO collections (user_id, title, description, cover_url, is_public)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at`,
userID, title, description, coverURL, isPublic,
).Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("create collection: %w", err)
}
return c, nil
}
// FindByID returns one collection by id (no owner check — caller decides
// what to do with the visibility flag).
func (r *CollectionRepo) FindByID(ctx context.Context, id string) (*model.CollectionRecord, error) {
c := &model.CollectionRecord{}
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at
FROM collections WHERE id = $1`, id,
).Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCollectionNotFound
}
return nil, fmt.Errorf("find collection: %w", err)
}
return c, nil
}
// ListByUser returns all collections for the caller (own listing).
func (r *CollectionRepo) ListByUser(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
return r.list(ctx, `WHERE user_id = $1 ORDER BY updated_at DESC`, userID)
}
// ListPublicByUser returns only the public collections for /u/<handle>.
func (r *CollectionRepo) ListPublicByUser(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
return r.list(ctx, `WHERE user_id = $1 AND is_public = TRUE ORDER BY updated_at DESC`, userID)
}
// CountByUserAndVisibility — used by the plan-limit enforcer
// (free: max 5 public, 5 private).
func (r *CollectionRepo) CountByUserAndVisibility(ctx context.Context, userID string, isPublic bool) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM collections WHERE user_id = $1 AND is_public = $2`,
userID, isPublic,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count collections: %w", err)
}
return n, nil
}
// Update modifies title/description/cover/visibility. Nil pointers mean
// "leave unchanged"; isPublic is unconditional (use a pointer if needed).
func (r *CollectionRepo) Update(ctx context.Context, id, userID string, title, description, coverURL *string, isPublic *bool) (*model.CollectionRecord, error) {
const q = `
UPDATE collections
SET title = COALESCE($3, title),
description = COALESCE($4, description),
cover_url = CASE WHEN $5::boolean THEN $6 ELSE cover_url END,
is_public = COALESCE($7, is_public),
updated_at = now()
WHERE id = $1 AND user_id = $2
RETURNING id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at`
updateCover := coverURL != nil
var coverVal *string
if updateCover && *coverURL != "" {
coverVal = coverURL
}
c := &model.CollectionRecord{}
err := r.pg.Pool.QueryRow(ctx, q, id, userID, title, description, updateCover, coverVal, isPublic).
Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCollectionNotFound
}
return nil, fmt.Errorf("update collection: %w", err)
}
return c, nil
}
// Delete removes a collection (cascades to collection_items via FK).
func (r *CollectionRepo) Delete(ctx context.Context, id, userID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM collections WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete collection: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrCollectionNotFound
}
return nil
}
// AddItem inserts a post into a collection. Idempotent — ON CONFLICT.
func (r *CollectionRepo) AddItem(ctx context.Context, collectionID, postID string) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO collection_items (collection_id, post_id, position)
VALUES ($1, $2, COALESCE(
(SELECT MAX(position) + 1 FROM collection_items WHERE collection_id = $1),
0
))
ON CONFLICT (collection_id, post_id) DO NOTHING`,
collectionID, postID,
)
if err != nil {
return fmt.Errorf("add collection item: %w", err)
}
return nil
}
// SetItemOrder rewrites positions for the given posts so the new order
// matches the input array. Runs in a single transaction so concurrent
// reads see either the old order or the new one — never a half-applied
// state. Posts not in the input list are left untouched (they keep
// whatever position they had); callers should pass the full membership
// when they want a clean ordering.
func (r *CollectionRepo) SetItemOrder(ctx context.Context, collectionID string, postIDs []string) error {
if len(postIDs) == 0 {
return nil
}
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
for i, pid := range postIDs {
if _, err := tx.Exec(ctx, `
UPDATE collection_items SET position = $1
WHERE collection_id = $2 AND post_id = $3`,
i, collectionID, pid,
); err != nil {
return fmt.Errorf("set position %d: %w", i, err)
}
}
return tx.Commit(ctx)
}
// RemoveItem deletes a post from a collection.
func (r *CollectionRepo) RemoveItem(ctx context.Context, collectionID, postID string) error {
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM collection_items WHERE collection_id = $1 AND post_id = $2`,
collectionID, postID,
)
if err != nil {
return fmt.Errorf("remove collection item: %w", err)
}
return nil
}
// CollectionsContaining returns the ids of collections owned by
// `userID` that already include `postID`. The drawer uses this to grey
// out rows the caller can't add the post to a second time.
func (r *CollectionRepo) CollectionsContaining(ctx context.Context, postID, userID string) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT ci.collection_id
FROM collection_items ci
JOIN collections c ON c.id = ci.collection_id
WHERE ci.post_id = $1 AND c.user_id = $2`,
postID, userID,
)
if err != nil {
return nil, fmt.Errorf("collections containing post: %w", err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan collection id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// PostsIn returns the posts (with images + author) inside a collection.
func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]model.Post, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
FROM collection_items ci
JOIN posts p ON p.id = ci.post_id
JOIN users u ON u.id = p.user_id
WHERE ci.collection_id = $1
ORDER BY ci.position, ci.added_at`,
collectionID,
)
if err != nil {
return nil, fmt.Errorf("query collection posts: %w", err)
}
defer rows.Close()
posts := []model.Post{}
ids := []string{}
for rows.Next() {
var p model.Post
if err := rows.Scan(
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
); err != nil {
return nil, fmt.Errorf("scan post: %w", err)
}
posts = append(posts, p)
ids = append(ids, p.ID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate collection posts: %w", err)
}
// Defer image hydration to caller via PostRepo if needed; for v1 we just
// return without images here (the management page can fetch separately).
return posts, nil
}
// --- internal helpers ---
func (r *CollectionRepo) list(ctx context.Context, where string, args ...any) ([]model.CollectionRecord, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at
FROM collections `+where, args...,
)
if err != nil {
return nil, fmt.Errorf("query collections: %w", err)
}
defer rows.Close()
out := []model.CollectionRecord{}
for rows.Next() {
var c model.CollectionRecord
if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan collection: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
+257
View File
@@ -0,0 +1,257 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// ErrPostNotFound is returned by lookup methods when no row matches.
var ErrPostNotFound = errors.New("post not found")
// PostRepo wraps Postgres access for posts + post_images.
type PostRepo struct{ pg *Postgres }
func NewPostRepo(pg *Postgres) *PostRepo { return &PostRepo{pg: pg} }
// Create inserts a post + its images in a single transaction so we never
// end up with orphan images.
func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
p := &model.Post{}
err = tx.QueryRow(ctx, `
INSERT INTO posts (user_id, caption, source_url, source_title)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, caption, source_url, COALESCE(source_title, ''), created_at, updated_at`,
userID, caption, sourceURL, sourceTitle,
).Scan(&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("insert post: %w", err)
}
for i, url := range imageURLs {
var img model.PostImage
err = tx.QueryRow(ctx, `
INSERT INTO post_images (post_id, url, position)
VALUES ($1, $2, $3)
RETURNING id, url, position`,
p.ID, url, i,
).Scan(&img.ID, &img.URL, &img.Position)
if err != nil {
return nil, fmt.Errorf("insert post image %d: %w", i, err)
}
p.Images = append(p.Images, img)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return p, nil
}
// ReplaceImages atomically swaps a post's image set. Ownership is
// enforced inside the tx by locking the post row and checking user_id —
// non-owners get ErrPostNotFound (we don't distinguish "not yours" from
// "doesn't exist" to non-owners). Existing rows are wiped, the new URLs
// are inserted with sequential positions, and the post's `updated_at`
// is touched so list views can sort by recency if needed later.
func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, imageURLs []string) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var owner string
if err := tx.QueryRow(ctx, `SELECT user_id FROM posts WHERE id = $1 FOR UPDATE`, postID).Scan(&owner); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrPostNotFound
}
return fmt.Errorf("lock post: %w", err)
}
if owner != userID {
return ErrPostNotFound
}
if _, err := tx.Exec(ctx, `DELETE FROM post_images WHERE post_id = $1`, postID); err != nil {
return fmt.Errorf("delete images: %w", err)
}
for i, url := range imageURLs {
if _, err := tx.Exec(ctx, `
INSERT INTO post_images (post_id, url, position)
VALUES ($1, $2, $3)`, postID, url, i,
); err != nil {
return fmt.Errorf("insert image %d: %w", i, err)
}
}
if _, err := tx.Exec(ctx, `UPDATE posts SET updated_at = now() WHERE id = $1`, postID); err != nil {
return fmt.Errorf("touch post: %w", err)
}
return tx.Commit(ctx)
}
// FindByID returns one post with its images and author info.
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
const q = `
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE p.id = $1`
p := &model.Post{}
err := r.pg.Pool.QueryRow(ctx, q, id).Scan(
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPostNotFound
}
return nil, fmt.Errorf("find post: %w", err)
}
imgs, err := r.imagesFor(ctx, []string{p.ID})
if err != nil {
return nil, err
}
p.Images = imgs[p.ID]
return p, nil
}
// ListByHandle returns the public feed for a user, newest-first, paginated.
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
var total int
if err := r.pg.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM posts p
JOIN users u ON u.id = p.user_id
WHERE u.handle = $1`, handle,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count posts: %w", err)
}
const q = `
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE u.handle = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, handle, limit, offset)
}
// ListByUser returns the user's own posts (private listing — same shape).
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
var total int
if err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count posts: %w", err)
}
const q = `
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE p.user_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset)
}
// Delete removes a post only if it belongs to the caller.
func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM posts WHERE id = $1 AND user_id = $2`, postID, userID)
if err != nil {
return fmt.Errorf("delete post: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrPostNotFound
}
return nil
}
// CountByUser is used by services that need to know how many posts a user
// has — currently the public-profile endpoint only.
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count user posts: %w", err)
}
return n, nil
}
// --- internal helpers ---
func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, args ...any) ([]model.Post, int, error) {
rows, err := r.pg.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("query posts: %w", err)
}
defer rows.Close()
posts := []model.Post{}
ids := []string{}
for rows.Next() {
var p model.Post
if err := rows.Scan(
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
); err != nil {
return nil, 0, fmt.Errorf("scan post: %w", err)
}
posts = append(posts, p)
ids = append(ids, p.ID)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate posts: %w", err)
}
if len(ids) > 0 {
imgs, err := r.imagesFor(ctx, ids)
if err != nil {
return nil, 0, err
}
for i := range posts {
posts[i].Images = imgs[posts[i].ID]
}
}
return posts, total, nil
}
// imagesFor returns a map post_id → [images...] for a batch of post IDs in
// a single query so we don't N+1 the feed endpoint.
func (r *PostRepo) imagesFor(ctx context.Context, postIDs []string) (map[string][]model.PostImage, error) {
out := make(map[string][]model.PostImage, len(postIDs))
if len(postIDs) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT post_id, id, url, position
FROM post_images
WHERE post_id = ANY($1)
ORDER BY post_id, position`, postIDs,
)
if err != nil {
return nil, fmt.Errorf("query post images: %w", err)
}
defer rows.Close()
for rows.Next() {
var postID string
var img model.PostImage
if err := rows.Scan(&postID, &img.ID, &img.URL, &img.Position); err != nil {
return nil, fmt.Errorf("scan post image: %w", err)
}
out[postID] = append(out[postID], img)
}
return out, rows.Err()
}
+109
View File
@@ -0,0 +1,109 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// R2 wraps an S3 client pointed at Cloudflare's R2 endpoint.
//
// We use R2 for avatar uploads through presigned PUT URLs:
//
// frontend asks BFF for a presigned URL ─► BFF returns URL + final public URL
// frontend PUTs the file directly to R2 ─► R2 stores it
// frontend POSTs the public URL back to BFF profile-update endpoint
//
// The BFF never proxies the file bytes; bandwidth stays browser↔R2.
type R2 struct {
client *s3.Client
bucket string
publicBase string // R2_PUBLIC_URL, no trailing slash
}
// R2Config captures the env vars needed to talk to R2.
type R2Config struct {
AccountID string
AccessKeyID string
SecretAccessKey string
Bucket string
PublicURL string
}
// Configured returns true if every required field is present.
func (c R2Config) Configured() bool {
return c.AccountID != "" && c.AccessKeyID != "" && c.SecretAccessKey != "" &&
c.Bucket != "" && c.PublicURL != ""
}
// NewR2 builds an S3 client pointed at the per-account R2 endpoint.
// Returns (nil, nil) if cfg is not Configured — the avatar route will then
// return 503 instead of panicking. That keeps the BFF bootable without R2.
func NewR2(cfg R2Config) (*R2, error) {
if !cfg.Configured() {
return nil, nil
}
endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.AccountID)
creds := credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")
client := s3.New(s3.Options{
Region: "auto", // R2 ignores region but the SDK requires one
Credentials: creds,
BaseEndpoint: aws.String(endpoint),
UsePathStyle: true,
})
return &R2{
client: client,
bucket: cfg.Bucket,
publicBase: trimTrailingSlash(cfg.PublicURL),
}, nil
}
// PresignPut returns a one-shot PUT URL the browser can use to upload a file
// directly to R2, plus the final public URL where the object will be served.
//
// expiry caps how long the upload URL is valid; 5 minutes is plenty and
// limits replay if the URL leaks.
func (r *R2) PresignPut(ctx context.Context, key, contentType string, expiry time.Duration) (uploadURL, publicURL string, err error) {
if r == nil {
return "", "", errors.New("R2 not configured")
}
if key == "" {
return "", "", errors.New("empty key")
}
presigner := s3.NewPresignClient(r.client)
req, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(r.bucket),
Key: aws.String(key),
ContentType: aws.String(contentType),
}, s3.WithPresignExpires(expiry))
if err != nil {
return "", "", fmt.Errorf("presign put: %w", err)
}
return req.URL, fmt.Sprintf("%s/%s", r.publicBase, key), nil
}
// PublicURL builds the public URL for an arbitrary key. Useful when reading
// existing user.avatar_url values back.
func (r *R2) PublicURL(key string) string {
if r == nil {
return ""
}
return fmt.Sprintf("%s/%s", r.publicBase, key)
}
func trimTrailingSlash(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
}
return s
}
+247
View File
@@ -0,0 +1,247 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// ErrUserNotFound is returned by lookup methods when no row matches.
var ErrUserNotFound = errors.New("user not found")
// ErrEmailTaken is returned by Create when the email already exists.
var ErrEmailTaken = errors.New("email already registered")
// ErrHandleTaken is returned when the chosen handle collides with an existing user.
var ErrHandleTaken = errors.New("handle already in use")
// UserRepo provides CRUD on the users table.
type UserRepo struct {
pg *Postgres
}
func NewUserRepo(pg *Postgres) *UserRepo {
return &UserRepo{pg: pg}
}
// Create inserts a new user. Returns ErrEmailTaken or ErrHandleTaken on
// unique-violation. The caller is responsible for picking a unique handle —
// the auth service does this with a retry loop.
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name, handle string) (*model.User, error) {
const q = `
INSERT INTO users (email, password_hash, name, handle)
VALUES ($1, $2, $3, $4)
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at`
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q,
strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle,
).Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
}
if isUniqueViolation(err, "users_email_key") {
return nil, ErrEmailTaken
}
return nil, fmt.Errorf("create user: %w", err)
}
return u, nil
}
// FindByEmail returns the user with the given email, or ErrUserNotFound.
func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User, error) {
return r.findOne(ctx,
`WHERE email = $1`, strings.ToLower(strings.TrimSpace(email)))
}
// FindByID returns the user with the given id, or ErrUserNotFound.
func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) {
return r.findOne(ctx, `WHERE id = $1`, id)
}
// FindByHandle returns the user with the given handle (case-insensitive
// thanks to the citext column), or ErrUserNotFound.
func (r *UserRepo) FindByHandle(ctx context.Context, handle string) (*model.User, error) {
return r.findOne(ctx, `WHERE handle = $1`, strings.TrimSpace(handle))
}
func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) {
q := `
SELECT id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at
FROM users ` + where + ` LIMIT 1`
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q, args...).
Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("find user: %w", err)
}
return u, nil
}
// UpdateProfile updates the user's name, avatar, and/or handle. Pass nil to
// leave a field unchanged. Returns the updated user.
//
// `handle` collisions surface as ErrHandleTaken so the handler can render
// a useful message.
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) {
const q = `
UPDATE users
SET name = COALESCE($2, name),
avatar_url = CASE WHEN $3::boolean THEN $4 ELSE avatar_url END,
handle = COALESCE($5, handle),
updated_at = now()
WHERE id = $1
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at`
// $3 is a flag: "did the caller intend to set avatar_url (including to NULL)?"
// If avatarURL is nil we leave it alone; otherwise we apply *avatarURL (which
// may itself be the empty string — caller decides if "" should map to NULL).
updateAvatar := avatarURL != nil
var avatarVal *string
if updateAvatar && *avatarURL != "" {
avatarVal = avatarURL
}
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal, handle).
Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
if isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
}
return nil, fmt.Errorf("update profile: %w", err)
}
return u, nil
}
// UpdatePassword sets a new password hash and bumps token_version so any
// existing JWTs immediately become invalid.
func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error {
const q = `
UPDATE users
SET password_hash = $2,
token_version = token_version + 1,
updated_at = now()
WHERE id = $1`
tag, err := r.pg.Pool.Exec(ctx, q, id, newHash)
if err != nil {
return fmt.Errorf("update password: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrUserNotFound
}
return nil
}
// MarkEmailVerified sets email_verified_at = now() if not already set.
func (r *UserRepo) MarkEmailVerified(ctx context.Context, id string) error {
const q = `UPDATE users SET email_verified_at = now(), updated_at = now()
WHERE id = $1 AND email_verified_at IS NULL`
_, err := r.pg.Pool.Exec(ctx, q, id)
if err != nil {
return fmt.Errorf("mark verified: %w", err)
}
return nil
}
// Helper for unique-violation detection. Avoids importing pgconn just for
// the SQLSTATE; we string-match on the message which contains the constraint
// name. Good enough for the one constraint we care about.
func isUniqueViolation(err error, constraint string) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "23505") || strings.Contains(msg, constraint)
}
// AuthTokenRepo handles password_reset + email_verification tokens.
type AuthTokenRepo struct {
pg *Postgres
}
func NewAuthTokenRepo(pg *Postgres) *AuthTokenRepo {
return &AuthTokenRepo{pg: pg}
}
// Create inserts a new token row. `tokenHash` must be the SHA-256 hex of the
// raw token; only the hash is stored, never the plaintext.
func (r *AuthTokenRepo) Create(ctx context.Context, userID, purpose, tokenHash string, ttl time.Duration) (*model.AuthToken, error) {
const q = `
INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, purpose, token_hash, expires_at, used_at, created_at`
t := &model.AuthToken{}
err := r.pg.Pool.QueryRow(ctx, q, userID, purpose, tokenHash, time.Now().Add(ttl)).
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
if err != nil {
return nil, fmt.Errorf("create auth_token: %w", err)
}
return t, nil
}
// FindValid returns a non-used, non-expired token by hash + purpose.
func (r *AuthTokenRepo) FindValid(ctx context.Context, tokenHash, purpose string) (*model.AuthToken, error) {
const q = `
SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at
FROM auth_tokens
WHERE token_hash = $1
AND purpose = $2
AND used_at IS NULL
AND expires_at > now()
LIMIT 1`
t := &model.AuthToken{}
err := r.pg.Pool.QueryRow(ctx, q, tokenHash, purpose).
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("find token: %w", err)
}
return t, nil
}
// Consume marks a token as used. After this call the token can never be
// honoured again, even if it has not yet expired.
func (r *AuthTokenRepo) Consume(ctx context.Context, id string) error {
const q = `UPDATE auth_tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL`
_, err := r.pg.Pool.Exec(ctx, q, id)
if err != nil {
return fmt.Errorf("consume token: %w", err)
}
return nil
}
// InvalidatePending marks all not-yet-used tokens for a user+purpose as used.
// Called when a fresh token is issued so old links are immediately dead.
func (r *AuthTokenRepo) InvalidatePending(ctx context.Context, userID, purpose string) error {
const q = `UPDATE auth_tokens SET used_at = now()
WHERE user_id = $1 AND purpose = $2 AND used_at IS NULL`
_, err := r.pg.Pool.Exec(ctx, q, userID, purpose)
if err != nil {
return fmt.Errorf("invalidate pending tokens: %w", err)
}
return nil
}
+181 -11
View File
@@ -12,7 +12,10 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/handler"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/internal/worker"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
"go.uber.org/zap"
)
@@ -41,6 +44,16 @@ type Server struct {
transcriptionH *handler.TranscriptionHandler
searchH *handler.SearchHandler
shortURLH *handler.ShortURLHandler
authH *handler.AuthHandler
userH *handler.UserHandler
postH *handler.PostHandler
bookmarkH *handler.BookmarkHandler
collectionH *handler.CollectionHandler
profileH *handler.ProfileHandler
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
authIssuer *auth.Issuer
userRepo *repository.UserRepo
}
// New creates a new Server with all routes registered.
@@ -86,10 +99,73 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
// Connection failures degrade gracefully — the BFF stays up; only /share returns 503.
s.initShortener(cfg, logger)
// Auth: depends on Postgres being up. initShortener already opened the pool,
// so we reuse s.pg here. On a fresh DB the users + auth_tokens tables get
// created by the migration runner that initShortener already invoked.
s.initAuth(cfg, logger)
s.setupRouter()
return s
}
// initAuth wires the user repo, JWT issuer, mailer, R2 client, and the auth
// + user handlers. If Postgres is unavailable, leaves the handlers nil and
// the routes 503-out gracefully.
func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
if s.pg == nil {
logger.Warn("auth: Postgres unavailable — /auth and /users will return 503")
return
}
userRepo := repository.NewUserRepo(s.pg)
tokenRepo := repository.NewAuthTokenRepo(s.pg)
r2, err := repository.NewR2(repository.R2Config{
AccountID: cfg.R2AccountID,
AccessKeyID: cfg.R2AccessKeyID,
SecretAccessKey: cfg.R2SecretAccessKey,
Bucket: cfg.R2Bucket,
PublicURL: cfg.R2PublicURL,
})
if err != nil {
logger.Warn("auth: R2 unavailable — avatar uploads will return 503", zap.Error(err))
}
if r2 == nil {
logger.Info("auth: R2 not configured — avatar presign endpoint will return 503")
}
issuer := auth.NewIssuer(cfg.JWTSecret, cfg.JWTExpiration)
mail := mailer.New(mailer.Config{
Host: cfg.SMTPHost,
Port: cfg.SMTPPort,
Username: cfg.SMTPUsername,
Password: cfg.SMTPPassword,
From: cfg.SMTPFrom,
}, logger)
authSvc := service.NewAuthService(userRepo, tokenRepo, issuer, mail, logger, cfg.FrontendURL)
userSvc := service.NewUserService(userRepo, r2)
// Social repositories + services share the same Postgres pool and R2 client.
postRepo := repository.NewPostRepo(s.pg)
bookmarkRepo := repository.NewBookmarkRepo(s.pg)
collectionRepo := repository.NewCollectionRepo(s.pg)
postSvc := service.NewPostService(postRepo, r2)
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
cookieOpts := auth.CookieOptions{Domain: cfg.CookieDomain, Secure: cfg.CookieSecure}
s.authIssuer = issuer
s.userRepo = userRepo
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
s.userH = handler.NewUserHandler(userSvc)
s.postH = handler.NewPostHandler(postSvc)
s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc)
s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo)
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo)
}
// initShortener wires Postgres + Redis + the shorturl handler/workers.
// On connection failure it logs and leaves the components nil — the handler
// short-circuits to 503 so the rest of the BFF keeps working.
@@ -152,6 +228,15 @@ func (s *Server) Close() {
s.logger.Info("all connections closed")
}
// authUnavailable is the 503 fallback used when Postgres is down and the
// auth handlers couldn't be wired up. Keeps every /auth/* and /users/* URL
// returning a structured error rather than 404.
func (s *Server) authUnavailable(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"success":false,"error":{"code":503,"message":"auth service unavailable"}}`))
}
// injectBookCode is a middleware that sets "code" in chi's URL params
// for explicit sub-routes like /books/sb and /books/cc where {code}
// isn't a URL param but a fixed path prefix.
@@ -367,20 +452,105 @@ func (s *Server) setupRouter() {
// Search (Typesense)
r.Get("/search", s.searchH.Search)
// User routes (PostgreSQL)
// --- Auth ---
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
// and email verification. The /auth/me + /auth/resend-verification routes
// require a session and live under the protected block below.
r.Route("/auth", func(r chi.Router) {
// TODO: register auth handlers (login, register, refresh)
})
r.Route("/users", func(r chi.Router) {
// TODO: register user handlers
})
r.Route("/collections", func(r chi.Router) {
// TODO: register collection/bookmark handlers
})
r.Route("/mentions", func(r chi.Router) {
// TODO: register mention handlers
if s.authH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Post("/signup", s.authH.SignUp)
r.Post("/login", s.authH.LogIn)
r.Post("/logout", s.authH.LogOut)
r.Post("/forgot-password", s.authH.ForgotPassword)
r.Post("/reset-password", s.authH.ResetPassword)
r.Post("/verify-email", s.authH.VerifyEmail)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/me", s.authH.Me)
r.Post("/resend-verification", s.authH.ResendVerification)
})
})
// User profile + avatar — all protected.
r.Route("/users", func(r chi.Router) {
if s.userH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Patch("/me", s.userH.UpdateProfile)
r.Post("/me/avatar-presign", s.userH.AvatarPresign)
})
})
// --- Social: posts ---
r.Route("/posts", func(r chi.Router) {
if s.postH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
// Public — read individual posts without auth.
r.Get("/{id}", s.postH.Get)
// Protected — create / delete / image presign / "my posts".
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Post("/", s.postH.Create)
r.Delete("/{id}", s.postH.Delete)
r.Put("/{id}/images", s.postH.ReplaceImages)
r.Get("/mine", s.postH.ListMine)
r.Post("/image-presign", s.postH.ImagePresign)
})
})
// --- Social: bookmarks (all protected; bookmarks are private by design) ---
r.Route("/bookmarks", func(r chi.Router) {
if s.bookmarkH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Post("/", s.bookmarkH.Create)
r.Get("/", s.bookmarkH.List)
r.Delete("/{id}", s.bookmarkH.Delete)
})
// --- Social: collections ---
// The single-collection GET is the only route that may be hit
// anonymously (public collections); everything else needs auth.
r.Route("/collections", func(r chi.Router) {
if s.collectionH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Get("/{id}", s.collectionH.Get)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Post("/", s.collectionH.Create)
r.Get("/", s.collectionH.ListMine)
r.Patch("/{id}", s.collectionH.Update)
r.Delete("/{id}", s.collectionH.Delete)
r.Post("/{id}/items", s.collectionH.AddItem)
r.Put("/{id}/items/order", s.collectionH.ReorderItems)
r.Delete("/{id}/items/{post_id}", s.collectionH.RemoveItem)
r.Get("/containing/{post_id}", s.collectionH.ContainingPost)
r.Post("/cover-presign", s.collectionH.CoverPresign)
})
})
// --- Public profile + feed ---
r.Route("/u", func(r chi.Router) {
if s.profileH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Get("/{handle}", s.profileH.Get)
r.Get("/{handle}/feed", s.profileH.Feed)
})
// URL Shortener — Postgres-backed, Redis-queued.
// The handler short-circuits to 503 if Postgres or Redis are unavailable.
r.Route("/share", func(r chi.Router) {
+356
View File
@@ -0,0 +1,356 @@
// Package service orchestrates the auth flows on top of the repositories.
//
// The handler layer is intentionally dumb: it parses the request, calls one
// AuthService method, and renders the result. All branching (already exists?
// wrong password? expired token?) lives here, behind a small set of named
// error sentinels.
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
// Sentinel errors. Handlers map these to HTTP status codes.
var (
ErrInvalidCredentials = errors.New("invalid email or password")
ErrEmailAlreadyTaken = errors.New("email already registered")
ErrUserNotFound = errors.New("user not found")
ErrInvalidOrExpiredLink = errors.New("link is invalid or has expired")
ErrEmailAlreadyVerified = errors.New("email already verified")
ErrWeakPassword = errors.New("password must be at least 8 characters")
ErrHandleTaken = errors.New("handle already in use")
)
// AuthService bundles every flow that mutates auth state.
type AuthService struct {
users *repository.UserRepo
tokens *repository.AuthTokenRepo
jwt *auth.Issuer
mail mailer.Mailer
logger *zap.Logger
frontend string // FrontendURL — used to build reset / verify links
// TTLs — surfaced as fields so tests can shrink them.
PasswordResetTTL time.Duration
EmailVerifyTTL time.Duration
}
// NewAuthService wires the dependencies.
func NewAuthService(
users *repository.UserRepo,
tokens *repository.AuthTokenRepo,
jwt *auth.Issuer,
mail mailer.Mailer,
logger *zap.Logger,
frontendURL string,
) *AuthService {
return &AuthService{
users: users,
tokens: tokens,
jwt: jwt,
mail: mail,
logger: logger,
frontend: strings.TrimRight(frontendURL, "/"),
PasswordResetTTL: 1 * time.Hour,
EmailVerifyTTL: 24 * time.Hour,
}
}
// AuthResult is the value handlers return on signup/login. Token is the raw
// JWT the handler will plant in the response cookie; the User is the public
// profile to ship in the response body.
type AuthResult struct {
User *model.User
Token string
Expires time.Time
}
// SignUp creates a new user, issues a session JWT, and fires off a
// verification email (best-effort — failure to send does not block signup,
// since the user can hit resend later).
func (s *AuthService) SignUp(ctx context.Context, email, password, name string) (*AuthResult, error) {
if len(password) < 8 {
return nil, ErrWeakPassword
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
// Generate a handle and retry up to 8 times if it collides with an
// existing user. The slug seed is derived from the email's local-part,
// which is the closest thing to a free-form identifier the user gave us.
base := slugForHandle(email)
var u *model.User
for attempt := 0; attempt < 8; attempt++ {
handle := base
if attempt > 0 {
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
}
u, err = s.users.Create(ctx, email, string(hash), strings.TrimSpace(name), handle)
if err == nil {
break
}
if errors.Is(err, repository.ErrEmailTaken) {
return nil, ErrEmailAlreadyTaken
}
if !errors.Is(err, repository.ErrHandleTaken) {
return nil, err
}
}
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
if err := s.sendVerificationEmail(ctx, u); err != nil {
// Log and keep going — signup itself succeeded.
s.logger.Warn("verification email failed", zap.String("user", u.ID), zap.Error(err))
}
return s.issueSession(u)
}
// LogIn authenticates a user by email + password. The error returned for an
// unknown email is intentionally identical to a wrong password — never leak
// which one was wrong.
func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthResult, error) {
u, err := s.users.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
return nil, ErrInvalidCredentials
}
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return s.issueSession(u)
}
// Me looks up a user by id and re-verifies the JWT's token_version against
// the stored value. Mismatch means the user has logged-out-everywhere or
// changed password since this JWT was issued → reject.
func (s *AuthService) Me(ctx context.Context, userID string, tokenVersion int) (*model.User, error) {
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, ErrUserNotFound
}
if u.TokenVersion != tokenVersion {
return nil, auth.ErrInvalidToken
}
return u, nil
}
// RequestPasswordReset always returns nil to callers — we don't want to
// reveal whether the email is registered. If the email exists we mint a
// token and email a link; otherwise we silently no-op.
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
u, err := s.users.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
return nil // do not reveal
}
return err
}
// Invalidate any previous reset tokens for this user.
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposePasswordReset)
raw, hash, err := newToken()
if err != nil {
return err
}
if _, err := s.tokens.Create(ctx, u.ID, model.PurposePasswordReset, hash, s.PasswordResetTTL); err != nil {
return err
}
link := fmt.Sprintf("%s/reset-password?token=%s", s.frontend, raw)
body := passwordResetEmail(u.Name, link)
if err := s.mail.Send(u.Email, "Reset your Prabhupāda Says password", body); err != nil {
s.logger.Warn("password reset email failed", zap.String("user", u.ID), zap.Error(err))
}
return nil
}
// ResetPassword consumes a reset token and sets a new password.
// On success the user's token_version is bumped, invalidating every
// outstanding JWT (so a stolen token can't be used after the legit owner
// changes their password).
func (s *AuthService) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
if len(newPassword) < 8 {
return ErrWeakPassword
}
hash := hashToken(rawToken)
t, err := s.tokens.FindValid(ctx, hash, model.PurposePasswordReset)
if err != nil {
return ErrInvalidOrExpiredLink
}
bcryptHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if err := s.users.UpdatePassword(ctx, t.UserID, string(bcryptHash)); err != nil {
return err
}
if err := s.tokens.Consume(ctx, t.ID); err != nil {
s.logger.Warn("consume reset token", zap.String("token_id", t.ID), zap.Error(err))
}
return nil
}
// VerifyEmail consumes an email-verification token and flips the user's
// email_verified_at timestamp.
func (s *AuthService) VerifyEmail(ctx context.Context, rawToken string) error {
hash := hashToken(rawToken)
t, err := s.tokens.FindValid(ctx, hash, model.PurposeEmailVerification)
if err != nil {
return ErrInvalidOrExpiredLink
}
if err := s.users.MarkEmailVerified(ctx, t.UserID); err != nil {
return err
}
if err := s.tokens.Consume(ctx, t.ID); err != nil {
s.logger.Warn("consume verify token", zap.String("token_id", t.ID), zap.Error(err))
}
return nil
}
// ResendVerification re-fires the verification email for the currently-logged-in
// user. No-ops with no error if the user is already verified.
func (s *AuthService) ResendVerification(ctx context.Context, userID string) error {
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return ErrUserNotFound
}
if u.EmailVerifiedAt != nil {
return ErrEmailAlreadyVerified
}
return s.sendVerificationEmail(ctx, u)
}
// --- internal helpers ---
func (s *AuthService) issueSession(u *model.User) (*AuthResult, error) {
tok, exp, err := s.jwt.Issue(u.ID, u.TokenVersion)
if err != nil {
return nil, err
}
return &AuthResult{User: u, Token: tok, Expires: exp}, nil
}
func (s *AuthService) sendVerificationEmail(ctx context.Context, u *model.User) error {
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeEmailVerification)
raw, hash, err := newToken()
if err != nil {
return err
}
if _, err := s.tokens.Create(ctx, u.ID, model.PurposeEmailVerification, hash, s.EmailVerifyTTL); err != nil {
return err
}
link := fmt.Sprintf("%s/verify-email?token=%s", s.frontend, raw)
body := verifyEmailMessage(u.Name, link)
return s.mail.Send(u.Email, "Verify your email — Prabhupāda Says", body)
}
// newToken returns (rawHex, sha256HexHash). The raw value goes in the
// emailed link; only the hash is stored in the DB.
func newToken() (raw, hash string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
raw = hex.EncodeToString(b)
hash = hashToken(raw)
return raw, hash, nil
}
// slugForHandle turns the local-part of an email into a kebab-case handle.
// Falls back to "user" for fully-stripped inputs so we always have a seed
// the retry loop can append a suffix to.
func slugForHandle(email string) string {
at := strings.IndexByte(email, '@')
local := email
if at >= 0 {
local = email[:at]
}
var b strings.Builder
b.Grow(len(local))
for _, r := range strings.ToLower(local) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '.', r == '_', r == '-':
b.WriteByte('-')
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
out = "user"
}
if len(out) > 32 {
out = out[:32]
}
return out
}
// randomHandleSuffix returns 5 hex characters (~20 bits of entropy) — plenty
// to disambiguate names that already exist in the user base.
func randomHandleSuffix() string {
b := make([]byte, 3)
_, _ = rand.Read(b)
return hex.EncodeToString(b)[:5]
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// --- email bodies (intentionally minimal HTML; auth mails should be plain) ---
func passwordResetEmail(name, link string) string {
return fmt.Sprintf(`<p>Hari Bol %s,</p>
<p>You (or someone using your email) asked to reset your password on
<strong>Prabhupāda Says</strong>. Click the link below to set a new one:</p>
<p><a href="%s">%s</a></p>
<p>This link expires in 1 hour. If you didn't request a reset, just ignore
this email — your password stays the same.</p>`, htmlEscape(name), link, link)
}
func verifyEmailMessage(name, link string) string {
return fmt.Sprintf(`<p>Hari Bol %s,</p>
<p>Welcome to <strong>Prabhupāda Says</strong>. Please confirm your email by
clicking the link below:</p>
<p><a href="%s">%s</a></p>
<p>This link expires in 24 hours. You can request a new one from the banner
shown at the top of the site after you log in.</p>`, htmlEscape(name), link, link)
}
func htmlEscape(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
)
return r.Replace(s)
}
+87
View File
@@ -0,0 +1,87 @@
package service
import (
"errors"
"fmt"
)
// Plan caps for the social features. Updates here propagate everywhere via
// EnforceBookmarkLimit / EnforceCollectionLimit.
const (
PlanFree = "free"
PlanPremium = "premium"
freeBookmarkLimit = 1
premiumBookmarkLimit = 5
freeCollectionPerVisibilityLimit = 5 // 5 public AND 5 private separately
// Premium has no collection cap.
)
// ErrPlanLimit signals that a free-plan limit blocks the requested action.
// Handlers surface this as HTTP 402 (Payment Required) so clients can show
// an upsell. The message tells the user what cap was hit.
var ErrPlanLimit = errors.New("plan limit reached")
// PlanLimitError carries detail about which limit was hit so the frontend
// can render a helpful message instead of a generic "upgrade to premium".
type PlanLimitError struct {
Resource string // e.g. "bookmarks", "private collections"
Limit int
Plan string
}
func (e *PlanLimitError) Error() string {
return fmt.Sprintf("%s limit reached (%d on %s plan)", e.Resource, e.Limit, e.Plan)
}
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
// EnforceBookmarkLimit returns nil if the user is allowed to create one more
// bookmark, or a PlanLimitError otherwise.
func EnforceBookmarkLimit(plan string, currentCount int) error {
limit := bookmarkLimitFor(plan)
if currentCount >= limit {
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: plan}
}
return nil
}
// EnforceCollectionLimit returns nil if the user is allowed to create one
// more collection of the given visibility.
//
// `isPublic=true` → checks against public-collection cap
// `isPublic=false` → checks against private-collection cap
// Premium users have no cap → always nil.
func EnforceCollectionLimit(plan string, isPublic bool, currentCount int) error {
if plan == PlanPremium {
return nil
}
limit := freeCollectionPerVisibilityLimit
if currentCount >= limit {
resource := "private collections"
if isPublic {
resource = "public collections"
}
return &PlanLimitError{Resource: resource, Limit: limit, Plan: plan}
}
return nil
}
func bookmarkLimitFor(plan string) int {
if plan == PlanPremium {
return premiumBookmarkLimit
}
return freeBookmarkLimit
}
// BookmarkLimitFor returns the cap (for showing "X / Y used" in the UI).
func BookmarkLimitFor(plan string) int { return bookmarkLimitFor(plan) }
// CollectionLimitFor returns the per-visibility cap. -1 means unlimited.
func CollectionLimitFor(plan string) int {
if plan == PlanPremium {
return -1
}
return freeCollectionPerVisibilityLimit
}
+391
View File
@@ -0,0 +1,391 @@
// Package service — social bits of the BFF.
//
// `social_service.go` bundles three lightweight services (Post, Bookmark,
// Collection) because each is small (10–40 LOC of logic) and they share the
// same R2 + UserRepo dependencies. Keeping them together keeps `server.go`'s
// init-wiring tidy without sprawling into 4 separate files.
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"path"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
// Sentinels surfaced to handlers.
var (
ErrForbidden = errors.New("forbidden")
ErrNotFound = errors.New("not found")
)
// ---------------------------------------------------------------------------
// PostService
// ---------------------------------------------------------------------------
type PostService struct {
posts *repository.PostRepo
r2 *repository.R2
}
func NewPostService(posts *repository.PostRepo, r2 *repository.R2) *PostService {
return &PostService{posts: posts, r2: r2}
}
// Create persists a new post. The caption is treated as opaque — we trust
// the frontend to send the exact text the user selected. Source URL must be
// a same-origin path (e.g. /books/bg/1/1?hl=…) — we strip the host if one
// was provided so links can't be hijacked.
func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
caption = strings.TrimSpace(caption)
if caption == "" {
return nil, errors.New("caption is required")
}
if len(caption) > 4000 {
return nil, errors.New("caption too long (max 4000)")
}
sourceURL = stripHost(strings.TrimSpace(sourceURL))
if sourceURL == "" || !strings.HasPrefix(sourceURL, "/") {
return nil, errors.New("source_url must be an absolute path starting with /")
}
if len(imageURLs) > 5 {
return nil, errors.New("a post can hold up to 5 images")
}
return s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
}
// Get returns one post (public).
func (s *PostService) Get(ctx context.Context, id string) (*model.Post, error) {
return s.posts.FindByID(ctx, id)
}
// Delete checks ownership and removes the post.
func (s *PostService) Delete(ctx context.Context, postID, callerID string) error {
return s.posts.Delete(ctx, postID, callerID)
}
// ReplaceImages swaps the image set on a post. The caller must own the
// post. Returns the fresh post payload so the client can reconcile its
// local state without a separate fetch.
func (s *PostService) ReplaceImages(ctx context.Context, postID, callerID string, imageURLs []string) (*model.Post, error) {
if err := s.posts.ReplaceImages(ctx, postID, callerID, imageURLs); err != nil {
return nil, err
}
return s.posts.FindByID(ctx, postID)
}
// ListByHandle is the public feed view.
func (s *PostService) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
if offset < 0 {
offset = 0
}
return s.posts.ListByHandle(ctx, handle, limit, offset)
}
// ListMine is the owner's view of their own posts (drafts feature isn't a
// thing yet, so this is equivalent to ListByHandle minus the handle lookup).
func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
if offset < 0 {
offset = 0
}
return s.posts.ListByUser(ctx, userID, limit, offset)
}
// ImagePresign returns a one-shot R2 PUT URL for a post image.
// The key is namespaced under post-images/<user_id>/<random>.<ext>. We
// don't tie it to a post_id at presign time because the post might not
// exist yet — the user uploads images first, then submits the post with
// the public URLs.
func (s *PostService) ImagePresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
return presignImage(ctx, s.r2, "post-images", userID, contentType)
}
// ---------------------------------------------------------------------------
// BookmarkService
// ---------------------------------------------------------------------------
type BookmarkService struct {
users *repository.UserRepo
bookmarks *repository.BookmarkRepo
}
func NewBookmarkService(users *repository.UserRepo, bookmarks *repository.BookmarkRepo) *BookmarkService {
return &BookmarkService{users: users, bookmarks: bookmarks}
}
// Create enforces the plan cap and inserts a new bookmark.
//
// If the same URL is already bookmarked (UNIQUE constraint), we return the
// existing row as a 200 idempotent response instead of an error — clicking
// "Bookmark" twice on the same passage should feel like a no-op.
func (s *BookmarkService) Create(ctx context.Context, userID, sourceURL, sourceTitle string, excerpt, note *string) (*model.Bookmark, error) {
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, err
}
current, err := s.bookmarks.CountByUser(ctx, userID)
if err != nil {
return nil, err
}
if err := EnforceBookmarkLimit(u.Plan, current); err != nil {
return nil, err
}
b, err := s.bookmarks.Create(ctx, userID, stripHost(sourceURL), sourceTitle, excerpt, note)
if err == nil {
return b, nil
}
if errors.Is(err, repository.ErrBookmarkExists) {
// Idempotent: return whatever is already there.
list, lerr := s.bookmarks.ListByUser(ctx, userID)
if lerr != nil {
return nil, lerr
}
for _, existing := range list {
if existing.SourceURL == stripHost(sourceURL) {
return &existing, nil
}
}
}
return nil, err
}
// List returns the caller's bookmarks.
func (s *BookmarkService) List(ctx context.Context, userID string) ([]model.Bookmark, error) {
return s.bookmarks.ListByUser(ctx, userID)
}
// Delete removes a bookmark belonging to the caller.
func (s *BookmarkService) Delete(ctx context.Context, id, userID string) error {
return s.bookmarks.Delete(ctx, id, userID)
}
// ---------------------------------------------------------------------------
// CollectionService
// ---------------------------------------------------------------------------
type CollectionService struct {
users *repository.UserRepo
collections *repository.CollectionRepo
posts *repository.PostRepo
r2 *repository.R2
}
func NewCollectionService(users *repository.UserRepo, collections *repository.CollectionRepo, posts *repository.PostRepo, r2 *repository.R2) *CollectionService {
return &CollectionService{users: users, collections: collections, posts: posts, r2: r2}
}
// Create enforces per-visibility plan caps and inserts.
func (s *CollectionService) Create(ctx context.Context, userID, title string, description, coverURL *string, isPublic bool) (*model.CollectionRecord, error) {
if title = strings.TrimSpace(title); title == "" {
return nil, errors.New("title is required")
}
if len(title) > 120 {
return nil, errors.New("title too long (max 120)")
}
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, err
}
current, err := s.collections.CountByUserAndVisibility(ctx, userID, isPublic)
if err != nil {
return nil, err
}
if err := EnforceCollectionLimit(u.Plan, isPublic, current); err != nil {
return nil, err
}
return s.collections.Create(ctx, userID, title, description, coverURL, isPublic)
}
// Update — only the owner may edit. Visibility changes that would exceed
// the plan cap are blocked.
func (s *CollectionService) Update(ctx context.Context, id, userID string, title, description, coverURL *string, isPublic *bool) (*model.CollectionRecord, error) {
c, err := s.collections.FindByID(ctx, id)
if err != nil {
return nil, err
}
if c.UserID != userID {
return nil, ErrForbidden
}
// If the caller is flipping visibility, make sure they wouldn't blow the cap.
if isPublic != nil && *isPublic != c.IsPublic {
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, err
}
current, err := s.collections.CountByUserAndVisibility(ctx, userID, *isPublic)
if err != nil {
return nil, err
}
if err := EnforceCollectionLimit(u.Plan, *isPublic, current); err != nil {
return nil, err
}
}
return s.collections.Update(ctx, id, userID, title, description, coverURL, isPublic)
}
// Delete — only the owner may delete.
func (s *CollectionService) Delete(ctx context.Context, id, userID string) error {
c, err := s.collections.FindByID(ctx, id)
if err != nil {
return err
}
if c.UserID != userID {
return ErrForbidden
}
return s.collections.Delete(ctx, id, userID)
}
// ListMine returns the caller's collections (both public and private).
func (s *CollectionService) ListMine(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
return s.collections.ListByUser(ctx, userID)
}
// AddItem inserts a post into a collection. Caller must own the collection.
// We don't require ownership of the *post* — collections can curate other
// users' public posts in a future iteration; for v1 the toolbar always
// creates a fresh post owned by the caller before calling this.
func (s *CollectionService) AddItem(ctx context.Context, collectionID, postID, callerID string) error {
c, err := s.collections.FindByID(ctx, collectionID)
if err != nil {
return err
}
if c.UserID != callerID {
return ErrForbidden
}
if _, err := s.posts.FindByID(ctx, postID); err != nil {
return err
}
return s.collections.AddItem(ctx, collectionID, postID)
}
// SetItemOrder updates positions for the given post ids so the new order
// matches the input array. Owner-only.
func (s *CollectionService) SetItemOrder(ctx context.Context, collectionID, callerID string, postIDs []string) error {
c, err := s.collections.FindByID(ctx, collectionID)
if err != nil {
return err
}
if c.UserID != callerID {
return ErrForbidden
}
return s.collections.SetItemOrder(ctx, collectionID, postIDs)
}
// CollectionsContaining returns the ids of the caller's collections
// that already include the given post. Used by the save-to-collection
// drawer to disable rows the post is already in.
func (s *CollectionService) CollectionsContaining(ctx context.Context, postID, callerID string) ([]string, error) {
return s.collections.CollectionsContaining(ctx, postID, callerID)
}
// RemoveItem detaches a post from a collection (owner only).
func (s *CollectionService) RemoveItem(ctx context.Context, collectionID, postID, callerID string) error {
c, err := s.collections.FindByID(ctx, collectionID)
if err != nil {
return err
}
if c.UserID != callerID {
return ErrForbidden
}
return s.collections.RemoveItem(ctx, collectionID, postID)
}
// Get returns one collection. Visibility is enforced by the caller —
// `viewerID` is the calling user id ("" if anonymous). If the collection is
// private and the viewer isn't the owner, returns ErrForbidden.
func (s *CollectionService) Get(ctx context.Context, id, viewerID string) (*model.CollectionRecord, []model.Post, error) {
c, err := s.collections.FindByID(ctx, id)
if err != nil {
return nil, nil, err
}
if !c.IsPublic && c.UserID != viewerID {
return nil, nil, ErrForbidden
}
posts, err := s.collections.PostsIn(ctx, id)
if err != nil {
return nil, nil, err
}
return c, posts, nil
}
// CoverPresign returns a one-shot R2 PUT URL for a collection cover image.
func (s *CollectionService) CoverPresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
return presignImage(ctx, s.r2, "collection-covers", userID, contentType)
}
// ---------------------------------------------------------------------------
// shared helpers
// ---------------------------------------------------------------------------
// presignImage builds an R2 presign for a user-scoped media key under the
// given prefix. The random suffix invalidates browser caches when the image
// is replaced.
func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) {
if r2 == nil {
return "", "", errors.New("media uploads are not configured")
}
ext, ok := imageExtensionFor(contentType)
if !ok {
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
}
suffix := make([]byte, 8)
if _, err := rand.Read(suffix); err != nil {
return "", "", err
}
key := path.Join(prefix, userID, hex.EncodeToString(suffix)+ext)
return r2.PresignPut(ctx, key, contentType, 5*time.Minute)
}
func imageExtensionFor(ct string) (string, bool) {
switch ct {
case "image/png":
return ".png", true
case "image/jpeg":
return ".jpg", true
case "image/webp":
return ".webp", true
case "image/gif":
return ".gif", true
default:
return "", false
}
}
// stripHost reduces "https://prabhupadasays.org/books/bg/1/1?hl=x" to
// "/books/bg/1/1?hl=x" so we never store arbitrary external URLs.
// Anything that doesn't parse cleanly is returned unchanged (and rejected
// later by the prefix-check in the service).
func stripHost(u string) string {
u = strings.TrimSpace(u)
if u == "" {
return u
}
if strings.HasPrefix(u, "/") {
return u
}
for _, prefix := range []string{"http://", "https://"} {
if strings.HasPrefix(u, prefix) {
rest := u[len(prefix):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
return rest[i:]
}
return "/"
}
}
return u
}
+128
View File
@@ -0,0 +1,128 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"path"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
// UserService handles profile-edit + avatar-upload flows.
type UserService struct {
users *repository.UserRepo
r2 *repository.R2
}
func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService {
return &UserService{users: users, r2: r2}
}
// UpdateProfile updates the user's name, avatar URL, and/or handle.
//
// `avatarURL` semantics:
//
// nil → don't touch
// "" → clear (remove avatar)
// "x" → set to "x"
//
// `handle` is normalized to the project's slug format before being passed
// to the DB so users can't sneak in spaces or punctuation. Empty after
// normalization → 400.
func (s *UserService) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) {
if name != nil {
trimmed := strings.TrimSpace(*name)
if trimmed == "" {
return nil, errors.New("name cannot be empty")
}
if len(trimmed) > 120 {
return nil, errors.New("name too long (max 120)")
}
name = &trimmed
}
if handle != nil {
clean := normalizeHandle(*handle)
if len(clean) < 3 {
return nil, errors.New("handle must be at least 3 characters (letters, digits, dashes)")
}
if len(clean) > 32 {
return nil, errors.New("handle too long (max 32)")
}
handle = &clean
}
return s.users.UpdateProfile(ctx, id, name, avatarURL, handle)
}
// normalizeHandle lowercases and strips anything that isn't [a-z0-9-]. Same
// rules as slugForHandle in auth_service, but exposed here because the
// profile-update path doesn't go through auth.
func normalizeHandle(raw string) string {
var b strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(raw)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '.', r == '_':
b.WriteByte('-')
}
}
out := strings.Trim(b.String(), "-")
// Collapse runs of dashes.
for strings.Contains(out, "--") {
out = strings.ReplaceAll(out, "--", "-")
}
return out
}
// AvatarPresign issues a one-shot presigned PUT URL the frontend uses to
// upload an avatar directly to R2.
//
// The key is namespaced under `avatars/<user_id>/<random>.<ext>` so:
// - we can list / clean up a user's avatars with one prefix scan
// - the random suffix forces cache invalidation when the avatar changes
// - a malicious user can't overwrite someone else's avatar (their JWT
// scopes the user_id portion of the key)
//
// Returns (uploadURL, publicURL, error). The frontend then PUTs to uploadURL
// and POSTs publicURL back as the new avatar.
func (s *UserService) AvatarPresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
if s.r2 == nil {
return "", "", errors.New("avatar uploads are not configured")
}
ext, ok := extensionFor(contentType)
if !ok {
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
}
suffix := make([]byte, 8)
if _, err := rand.Read(suffix); err != nil {
return "", "", err
}
key := path.Join("avatars", userID, hex.EncodeToString(suffix)+ext)
return s.r2.PresignPut(ctx, key, contentType, 5*time.Minute)
}
// extensionFor whitelists the image content types we accept. Restricting
// here is the cheap-but-effective defence against users uploading arbitrary
// binaries to our R2 bucket.
func extensionFor(ct string) (string, bool) {
switch ct {
case "image/png":
return ".png", true
case "image/jpeg":
return ".jpg", true
case "image/webp":
return ".webp", true
case "image/gif":
return ".gif", true
default:
return "", false
}
}
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS users;
+31
View File
@@ -0,0 +1,31 @@
-- Required Postgres extensions:
-- pgcrypto → gen_random_uuid() for primary keys
-- citext → case-insensitive emails (so "Foo@x.com" == "foo@x.com")
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS citext;
-- Users table. Stores all auth + profile data.
--
-- Fields:
-- email unique, citext (case-insensitive)
-- password_hash bcrypt hash; never logged or returned in API responses
-- email_verified_at NULL until the user clicks the verification link
-- avatar_url absolute URL to a file in the R2 bucket (or NULL)
-- token_version incremented whenever issued JWTs should be invalidated
-- (password change, forced logout). JWTs carry this value
-- as a "tv" claim; mismatch ⇒ middleware rejects the token.
-- role "user" | "admin" | "moderator"
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email CITEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name VARCHAR(120) NOT NULL,
avatar_url TEXT,
role VARCHAR(32) NOT NULL DEFAULT 'user',
email_verified_at TIMESTAMPTZ,
token_version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS users_created_at_idx ON users (created_at DESC);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS auth_tokens;
+28
View File
@@ -0,0 +1,28 @@
-- One-shot tokens used by the forgot-password and email-verification flows.
--
-- We never store the raw token; only the SHA-256 hash. The plaintext lives
-- only in the email link. This means a DB leak can't be replayed against the
-- live flow.
--
-- `purpose` discriminates the two flows so we can keep one narrow table:
-- 'password_reset' → expires in ~1h
-- 'email_verification' → expires in ~24h
--
-- `used_at` is set the first time a token is consumed. The handler must check
-- both `used_at IS NULL` AND `expires_at > now()` before honouring a token.
CREATE TABLE IF NOT EXISTS auth_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
purpose VARCHAR(32) NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (purpose IN ('password_reset', 'email_verification'))
);
CREATE INDEX IF NOT EXISTS auth_tokens_user_purpose_idx
ON auth_tokens (user_id, purpose);
CREATE INDEX IF NOT EXISTS auth_tokens_expires_at_idx
ON auth_tokens (expires_at);
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN IF EXISTS handle;
ALTER TABLE users DROP COLUMN IF EXISTS plan;
@@ -0,0 +1,31 @@
-- Adds the social-handle + subscription-plan columns to `users`.
--
-- handle — case-insensitive (citext) URL slug, e.g. /u/satya-das.
-- Auto-generated from the email-local part on signup; user can
-- change it later via PATCH /users/me. Unique across the table.
-- plan — 'free' (default) or 'premium'. Controls limits enforced at
-- the service layer (bookmarks count, collection counts).
-- No checkout flow yet — admin flips it with SQL.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS handle CITEXT,
ADD COLUMN IF NOT EXISTS plan VARCHAR(16) NOT NULL DEFAULT 'free'
CHECK (plan IN ('free', 'premium'));
-- Backfill handles for any rows that already exist. The recipe mirrors the
-- generator the auth service uses: slugify the local-part of the email and
-- append a 6-char random suffix to make collisions vanishingly unlikely.
UPDATE users
SET handle = LOWER(REGEXP_REPLACE(SPLIT_PART(email::text, '@', 1), '[^a-z0-9]', '', 'g'))
|| '-' || substr(md5(id::text), 1, 6)
WHERE handle IS NULL;
ALTER TABLE users ALTER COLUMN handle SET NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'users_handle_key'
) THEN
ALTER TABLE users ADD CONSTRAINT users_handle_key UNIQUE (handle);
END IF;
END$$;
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS post_images;
DROP TABLE IF EXISTS posts;
+30
View File
@@ -0,0 +1,30 @@
-- Posts are public quotes pulled from the knowledgebase. Each carries the
-- selected text verbatim as its caption (immutable after create) plus a link
-- back to the source URL the user was reading. Optional images are stored in
-- the `post_images` table, one row per image, ordered by `position`.
--
-- Posts are always public — visibility is a per-user concern (the user's
-- /u/<handle> feed), not a per-post one. Private saves should use bookmarks.
CREATE TABLE IF NOT EXISTS posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
caption TEXT NOT NULL,
source_url TEXT NOT NULL,
source_title VARCHAR(200),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS posts_user_created_idx
ON posts (user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS post_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
url TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS post_images_post_idx
ON post_images (post_id, position);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS bookmarks;
+21
View File
@@ -0,0 +1,21 @@
-- Bookmarks are always private — they're the user's "save my place" markers.
-- We don't store the full passage text, only the source URL + a short excerpt
-- so the bookmarks index can render meaningful previews without a separate
-- content fetch.
--
-- UNIQUE (user_id, source_url) means re-bookmarking the same passage is a
-- no-op rather than producing a duplicate; the service maps the unique
-- violation to a 200 idempotent response.
CREATE TABLE IF NOT EXISTS bookmarks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
source_url TEXT NOT NULL,
source_title VARCHAR(200),
excerpt TEXT,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, source_url)
);
CREATE INDEX IF NOT EXISTS bookmarks_user_created_idx
ON bookmarks (user_id, created_at DESC);
@@ -0,0 +1,4 @@
DROP TRIGGER IF EXISTS collection_items_count_trg ON collection_items;
DROP FUNCTION IF EXISTS sync_collection_item_count();
DROP TABLE IF EXISTS collection_items;
DROP TABLE IF EXISTS collections;
+54
View File
@@ -0,0 +1,54 @@
-- Collections group posts/quotes. They carry their own visibility flag —
-- a private collection holds posts that are themselves public on the user's
-- feed, but the collection page is only visible to the owner.
--
-- `item_count` is denormalized so listing pages don't need a join+count.
-- It's kept in sync by collection_items triggers below.
CREATE TABLE IF NOT EXISTS collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(120) NOT NULL,
description TEXT,
cover_url TEXT,
is_public BOOLEAN NOT NULL DEFAULT FALSE,
item_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS collections_user_created_idx
ON collections (user_id, created_at DESC);
-- Public-collection lookups by owner+visibility (for /u/<handle> page).
CREATE INDEX IF NOT EXISTS collections_user_public_idx
ON collections (user_id, is_public) WHERE is_public = TRUE;
CREATE TABLE IF NOT EXISTS collection_items (
collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
position INT NOT NULL DEFAULT 0,
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (collection_id, post_id)
);
CREATE INDEX IF NOT EXISTS collection_items_position_idx
ON collection_items (collection_id, position);
-- Keep item_count in sync.
CREATE OR REPLACE FUNCTION sync_collection_item_count() RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE collections SET item_count = item_count + 1, updated_at = now()
WHERE id = NEW.collection_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE collections SET item_count = GREATEST(item_count - 1, 0), updated_at = now()
WHERE id = OLD.collection_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS collection_items_count_trg ON collection_items;
CREATE TRIGGER collection_items_count_trg
AFTER INSERT OR DELETE ON collection_items
FOR EACH ROW EXECUTE FUNCTION sync_collection_item_count();
+56
View File
@@ -0,0 +1,56 @@
package auth
import (
"net/http"
"time"
)
// CookieName is the single httpOnly cookie that carries the JWT.
const CookieName = "spsays_auth"
// CookieOptions captures the per-environment knobs for the auth cookie.
type CookieOptions struct {
Domain string // empty in dev; ".prabhupadasays.org" in prod for subdomain sharing
Secure bool // true in production (HTTPS-only)
}
// Set writes the JWT into a Secure, httpOnly, SameSite=Lax cookie.
// SameSite=Lax + same-registrable-domain (prabhupadasays.org / api.prabhupadasays.org)
// is enough for cross-subdomain auth without resorting to SameSite=None.
func Set(w http.ResponseWriter, token string, expires time.Time, opts CookieOptions) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
Domain: opts.Domain,
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
Secure: opts.Secure,
SameSite: http.SameSiteLaxMode,
})
}
// Clear overwrites the auth cookie with an immediately-expired one.
func Clear(w http.ResponseWriter, opts CookieOptions) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
Domain: opts.Domain,
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: opts.Secure,
SameSite: http.SameSiteLaxMode,
})
}
// Read pulls the raw JWT out of the request cookie. Returns "" if missing.
func Read(r *http.Request) string {
c, err := r.Cookie(CookieName)
if err != nil {
return ""
}
return c.Value
}
+83
View File
@@ -0,0 +1,83 @@
// Package auth provides JWT issuance + verification for the BFF.
//
// Tokens carry these claims:
//
// sub user id (UUID string)
// tv token_version snapshot — middleware rejects if it doesn't match the
// current value on the user row. Bumping the user's token_version
// invalidates every JWT ever issued to that user (used on password
// change and forced logout).
// iat issued-at
// exp expiry
package auth
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims is the JWT payload we sign + verify.
type Claims struct {
UserID string `json:"sub"`
TokenVersion int `json:"tv"`
jwt.RegisteredClaims
}
// Issuer issues and verifies tokens with a single shared HMAC secret.
type Issuer struct {
secret []byte
ttl time.Duration
}
// NewIssuer returns an Issuer configured with the given HMAC secret and TTL.
// The TTL is applied to every token issued via Issue().
func NewIssuer(secret string, ttl time.Duration) *Issuer {
return &Issuer{secret: []byte(secret), ttl: ttl}
}
// Issue mints a signed JWT for the given user.
func (i *Issuer) Issue(userID string, tokenVersion int) (string, time.Time, error) {
exp := time.Now().Add(i.ttl)
claims := &Claims{
UserID: userID,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(exp),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := tok.SignedString(i.secret)
if err != nil {
return "", time.Time{}, fmt.Errorf("sign jwt: %w", err)
}
return signed, exp, nil
}
// ErrInvalidToken is returned by Parse for any verification failure.
var ErrInvalidToken = errors.New("invalid or expired token")
// Parse validates the token's signature + expiry and returns the claims.
// Callers must additionally compare claims.TokenVersion against the user's
// current token_version (the JWT alone can't tell us whether the user has
// since bumped their version).
func (i *Issuer) Parse(raw string) (*Claims, error) {
claims := &Claims{}
tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return i.secret, nil
})
if err != nil || !tok.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
// TTL returns the configured token lifetime. Handlers use it to set cookie
// Max-Age so the cookie and the JWT expire in lockstep.
func (i *Issuer) TTL() time.Duration { return i.ttl }
+103
View File
@@ -0,0 +1,103 @@
// Package mailer is a thin SMTP wrapper used by the auth flows.
//
// The stdlib `net/smtp` is sufficient for our needs (password reset +
// email verification — low volume, plain-MIME). We keep one interface
// (Mailer) so handlers can be tested with an in-memory implementation
// and so we can swap to a transactional provider later (Resend, SES)
// without touching the call sites.
package mailer
import (
"fmt"
"net/smtp"
"strings"
"go.uber.org/zap"
)
// Mailer is the small surface auth services depend on.
type Mailer interface {
Send(to, subject, htmlBody string) error
}
// Config carries the per-environment SMTP knobs.
type Config struct {
Host string
Port int
Username string
Password string
From string // "Name <addr@host>" or just "addr@host"
}
// Configured returns true if enough SMTP creds are present to send mail.
// Used by the auth service to decide whether to send-or-log.
func (c Config) Configured() bool { return c.Host != "" && c.From != "" }
// SMTP is the production Mailer. Connects per call (low volume → fine).
type SMTP struct {
cfg Config
logger *zap.Logger
}
// New returns an SMTP mailer if creds are present, otherwise a NoopLogger
// that logs the message body to zap (useful in dev — the password-reset
// link shows up in your server logs).
func New(cfg Config, logger *zap.Logger) Mailer {
if !cfg.Configured() {
logger.Warn("SMTP not configured — mailer will log messages instead of sending")
return &NoopLogger{logger: logger}
}
return &SMTP{cfg: cfg, logger: logger}
}
// Send delivers a single HTML email. Plain-text alternative is omitted —
// the templates are short and modern clients render HTML fine.
func (m *SMTP) Send(to, subject, htmlBody string) error {
addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port)
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
var msg strings.Builder
msg.WriteString("From: ")
msg.WriteString(m.cfg.From)
msg.WriteString("\r\n")
msg.WriteString("To: ")
msg.WriteString(to)
msg.WriteString("\r\n")
msg.WriteString("Subject: ")
msg.WriteString(subject)
msg.WriteString("\r\n")
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
msg.WriteString(htmlBody)
// Most relays on port 587 want STARTTLS + AUTH. Port 465 = implicit TLS,
// not handled here — add only if needed.
if err := smtp.SendMail(addr, auth, fromAddr(m.cfg.From), []string{to}, []byte(msg.String())); err != nil {
return fmt.Errorf("smtp send: %w", err)
}
return nil
}
// fromAddr extracts the bare address from a "Name <addr>" string. SendMail's
// `from` argument is the envelope-from, which must be the bare address.
func fromAddr(s string) string {
if i := strings.LastIndex(s, "<"); i >= 0 {
if j := strings.LastIndex(s, ">"); j > i {
return s[i+1 : j]
}
}
return s
}
// NoopLogger satisfies Mailer by writing the message into the application
// logger. Use it in development so you can copy reset links from the console.
type NoopLogger struct{ logger *zap.Logger }
func (n *NoopLogger) Send(to, subject, htmlBody string) error {
n.logger.Info("📧 mailer (not sent — SMTP unconfigured)",
zap.String("to", to),
zap.String("subject", subject),
zap.String("body", htmlBody))
return nil
}