84 changed files with 8985 additions and 128 deletions
+33 -11
View File
@@ -9,19 +9,33 @@ MONGO_DB=spsays
# PostgreSQL (users, relations) # PostgreSQL (users, relations)
POSTGRES_URI=postgres://user:pass@localhost:5432/spsays?sslmode=disable POSTGRES_URI=postgres://user:pass@localhost:5432/spsays?sslmode=disable
# Meilisearch # Typesense
MEILISEARCH_HOST=http://localhost:7700 TYPESENSE_URL=https://search.prabhupadasays.org
MEILISEARCH_API_KEY= TYPESENSE_API_KEY=
# DragonflyDB / Redis # DragonflyDB / Redis
REDIS_URI=redis://localhost:6379 REDIS_URI=redis://localhost:6379
# S3 # Cloudflare R2 (avatar uploads via presigned URLs)
S3_REGION= # R2 endpoint is auto-built as https://<account_id>.r2.cloudflarestorage.com
S3_BUCKET= R2_ACCOUNT_ID=
S3_ACCESS_KEY= R2_ACCESS_KEY_ID=
S3_SECRET_KEY= R2_SECRET_ACCESS_KEY=
S3_ENDPOINT= 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 + /feedback admin notifications)
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM="Prabhupāda Says <noreply@prabhupadasays.org>"
# Comma-separated recipients for /feedback admin notifications. Empty
# disables the email fan-out (feedback rows still persist and show on
# the /stats page either way).
FEEDBACK_ADMIN_EMAILS=
# Push Notifications # Push Notifications
FCM_CREDENTIALS_FILE= FCM_CREDENTIALS_FILE=
@@ -32,7 +46,15 @@ APNS_BUNDLE_ID=
# JWT # JWT
JWT_SECRET=change-me-in-production 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 FRONTEND_URL=http://localhost:3000
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
FACEBOOK_OAUTH_CLIENT_ID=
FACEBOOK_OAUTH_CLIENT_SECRET=
+18 -3
View File
@@ -3,18 +3,34 @@ module github.com/prabhupadasays/spsays-bff
go 1.25.0 go 1.25.0
require ( require (
github.com/aws/aws-sdk-go-v2 v1.41.9
github.com/aws/aws-sdk-go-v2/credentials v1.19.19
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2
github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.19.0 github.com/redis/go-redis/v9 v9.19.0
github.com/typesense/typesense-go/v2 v2.0.0 github.com/typesense/typesense-go/v2 v2.0.0
go.mongodb.org/mongo-driver/v2 v2.2.1 go.mongodb.org/mongo-driver/v2 v2.2.1
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.52.0
golang.org/x/oauth2 v0.36.0
) )
require ( require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // 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/smithy-go v1.26.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/golang/snappy v1.0.0 // indirect github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.5.0 // indirect github.com/google/uuid v1.5.0 // indirect
@@ -30,7 +46,6 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr 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.20.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.org/x/text v0.29.0 // indirect
) )
+38 -8
View File
@@ -1,6 +1,32 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= 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 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= 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/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/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/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/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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
@@ -15,6 +41,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/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 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 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 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -79,31 +107,33 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 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/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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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/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= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 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-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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+101 -19
View File
@@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"strings"
"time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
@@ -28,33 +30,64 @@ type Config struct {
// DragonflyDB / Redis // DragonflyDB / Redis
RedisURI string RedisURI string
// AWS S3 // Cloudflare R2 (S3-compatible) — for avatar uploads via presigned URLs.
S3Region string R2AccountID string
S3Bucket string R2AccessKeyID string
S3AccessKey string R2SecretAccessKey string
S3SecretKey string R2Bucket string
S3Endpoint string // optional — for S3-compatible services (MinIO, etc.) R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL
// Push Notifications // SMTP — used for password-reset, email-verification, and
FCMCredentialsFile string // path to Firebase service account JSON // /feedback admin-notification mails.
APNsKeyFile string // path to APNs auth key SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPFrom string // "Prabhupāda Says <noreply@prabhupadasays.org>"
// Comma-separated FEEDBACK_ADMIN_EMAILS — recipients for the
// /feedback notification fan-out. Empty disables the email send
// (the DB row is still written and visible via /stats).
FeedbackAdminEmails []string
// Push Notifications (existing — unchanged).
FCMCredentialsFile string
APNsKeyFile string
APNsKeyID string APNsKeyID string
APNsTeamID string APNsTeamID string
APNsBundleID string APNsBundleID string
// JWT Auth // JWT Auth
JWTSecret string 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 // Frontend
FrontendURL string // for CORS FrontendURL string // for CORS + reset/verify links in emails
// BFF public URL — the externally-reachable origin of this server. Used
// to build the OAuth redirect_uri the provider sees (must match the
// allow-list configured in the provider's console). In dev this is
// usually http://localhost:8080.
BFFPublicURL string
// OAuth providers — empty creds disable the corresponding provider.
GoogleOAuthClientID string
GoogleOAuthClientSecret string
FacebookOAuthClientID string
FacebookOAuthClientSecret string
} }
// Load reads configuration from environment variables (with .env fallback). // Load reads configuration from environment variables (with .env fallback).
func Load() (*Config, error) { func Load() (*Config, error) {
// Load .env file if present (ignored in production)
_ = godotenv.Load() _ = godotenv.Load()
jwtExp, err := time.ParseDuration(getEnv("JWT_EXPIRATION", "168h"))
if err != nil {
return nil, fmt.Errorf("invalid JWT_EXPIRATION: %w", err)
}
cfg := &Config{ cfg := &Config{
Env: getEnv("APP_ENV", "development"), Env: getEnv("APP_ENV", "development"),
Port: getEnvInt("PORT", 8080), Port: getEnvInt("PORT", 8080),
@@ -69,11 +102,19 @@ func Load() (*Config, error) {
RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"), RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"),
S3Region: getEnv("S3_REGION", ""), R2AccountID: getEnv("R2_ACCOUNT_ID", ""),
S3Bucket: getEnv("S3_BUCKET", ""), R2AccessKeyID: getEnv("R2_ACCESS_KEY_ID", ""),
S3AccessKey: getEnv("S3_ACCESS_KEY", ""), R2SecretAccessKey: getEnv("R2_SECRET_ACCESS_KEY", ""),
S3SecretKey: getEnv("S3_SECRET_KEY", ""), R2Bucket: getEnv("R2_BUCKET", ""),
S3Endpoint: getEnv("S3_ENDPOINT", ""), 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"),
FeedbackAdminEmails: parseList(getEnv("FEEDBACK_ADMIN_EMAILS", "")),
FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""), FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""),
APNsKeyFile: getEnv("APNS_KEY_FILE", ""), APNsKeyFile: getEnv("APNS_KEY_FILE", ""),
@@ -82,15 +123,30 @@ func Load() (*Config, error) {
APNsBundleID: getEnv("APNS_BUNDLE_ID", ""), APNsBundleID: getEnv("APNS_BUNDLE_ID", ""),
JWTSecret: getEnv("JWT_SECRET", ""), 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"), FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
GoogleOAuthClientID: getEnv("GOOGLE_OAUTH_CLIENT_ID", ""),
GoogleOAuthClientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET", ""),
FacebookOAuthClientID: getEnv("FACEBOOK_OAUTH_CLIENT_ID", ""),
FacebookOAuthClientSecret: getEnv("FACEBOOK_OAUTH_CLIENT_SECRET", ""),
} }
// Validate required fields
if cfg.MongoURI == "" { if cfg.MongoURI == "" {
return nil, fmt.Errorf("MONGO_URI is required") 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 return cfg, nil
} }
@@ -110,3 +166,29 @@ func getEnvInt(key string, fallback int) int {
} }
return fallback 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
}
// parseList splits a comma-separated env value, trimming whitespace
// and discarding empty entries. Returns nil for empty input so
// callers can range over it safely without a length check.
func parseList(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+381
View File
@@ -0,0 +1,381 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"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/geoip"
"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"`
}
type verifyLoginOTPReq struct {
Email string `json:"email"`
Password string `json:"password"`
OTP string `json:"otp"`
}
type setEmailReq struct {
Email string `json:"email"`
}
type changePasswordReq struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// --- 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
}
h.captureCountry(r, res.User.ID)
auth.Set(w, res.Token, res.Expires, h.cookies)
response.JSON(w, http.StatusCreated, res.User)
}
// captureCountry records the new account's country off the critical path.
// Cloudflare's CF-IPCountry header is preferred (no network); when absent
// — as in the current DNS-only setup — we fall back to a GeoIP lookup on
// the client IP. The header read + IP extraction happen synchronously
// (they need the live request), then the network lookup + DB write run in
// a goroutine so signup latency is untouched. Any failure is silent: the
// account simply keeps country = NULL until the user sets it themselves.
func (h *AuthHandler) captureCountry(r *http.Request, userID string) {
cfCountry := geoip.CountryFromCloudflare(r)
ip := geoip.ClientIP(r)
go func() {
cc := cfCountry
if cc == "" {
lookupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
cc = geoip.LookupCountryByIP(lookupCtx, ip)
cancel()
}
if cc == "" {
return
}
writeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
h.svc.CaptureCountry(writeCtx, userID, cc)
}()
}
// LogIn authenticates an existing user. The response is one of:
//
// - 200 OK with the User JSON — credentials verified, session cookie set,
// frontend treats this as fully signed in.
// - 202 Accepted with `{ "otp_required": true }` — credentials verified
// but the account has been unverified for over 30 days. An OTP has been
// emailed; frontend must collect it and call VerifyLoginOTP next. No
// session cookie is set on this branch.
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
}
if res.OTPRequired {
response.JSON(w, http.StatusAccepted, map[string]any{
"otp_required": true,
})
return
}
auth.Set(w, res.Auth.Token, res.Auth.Expires, h.cookies)
response.JSON(w, http.StatusOK, res.Auth.User)
}
// VerifyLoginOTP completes a login that was gated by the stale-unverified
// OTP path. Re-checks the password (the OTP alone isn't enough — an
// attacker who steals the OTP email still needs the password), validates
// the OTP, flips email_verified_at to now(), and sets the session cookie.
func (h *AuthHandler) VerifyLoginOTP(w http.ResponseWriter, r *http.Request) {
var req verifyLoginOTPReq
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)
validator.Required(v, "otp", req.OTP)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
res, err := h.svc.VerifyLoginOTP(r.Context(), req.Email, req.Password, req.OTP)
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})
}
// ChangePassword — protected — replaces the caller's password after
// verifying the current one. The new password bumps token_version, which
// invalidates every previously-issued JWT for this user. We immediately
// re-issue a fresh session cookie so the caller's current device stays
// logged in; every other device gets signed out on next request.
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req changePasswordReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
// current_password is optional — accounts with no password on file
// (OAuth-only signups setting their first one) skip it. The service
// enforces verification when a hash exists.
v := validator.New()
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
}
res, err := h.svc.ChangePassword(r.Context(), uid, req.CurrentPassword, req.NewPassword)
if err != nil {
h.renderAuthErr(w, err)
return
}
auth.Set(w, res.Token, res.Expires, h.cookies)
response.JSON(w, http.StatusOK, res.User)
}
// 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})
}
// SetEmail — protected — sets (or replaces) the caller's email address.
// Used by OAuth-signup users who landed without one, and by anyone wanting
// to change their address. The new value is stored as unverified and a
// verification link is mailed out immediately.
func (h *AuthHandler) SetEmail(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req setEmailReq
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
}
u, err := h.svc.SetEmail(r.Context(), uid, req.Email)
if err != nil {
h.renderAuthErr(w, err)
return
}
response.JSON(w, http.StatusOK, u)
}
// LinkedProviders — protected — returns the OAuth provider names already
// linked to the caller's account. Settings UI uses this to flip each
// social-connect row between "Connect" and "Connected".
func (h *AuthHandler) LinkedProviders(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
providers, err := h.svc.LinkedProviders(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load linked providers")
return
}
response.JSON(w, http.StatusOK, map[string]any{"providers": providers})
}
// 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")
}
}
+70
View File
@@ -0,0 +1,70 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
"github.com/prabhupadasays/spsays-bff/pkg/validator"
)
// FeedbackHandler exposes POST /api/v1/feedback. The router wraps it
// with OptionalAuth so a session cookie populates user_id when one is
// present, and anonymous callers are still accepted (they have to
// supply contact_email instead).
type FeedbackHandler struct{ svc *service.FeedbackService }
func NewFeedbackHandler(svc *service.FeedbackService) *FeedbackHandler {
return &FeedbackHandler{svc: svc}
}
type submitFeedbackReq struct {
Category string `json:"category"`
Message string `json:"message"`
ContactEmail *string `json:"contact_email,omitempty"`
}
func (h *FeedbackHandler) Submit(w http.ResponseWriter, r *http.Request) {
var req submitFeedbackReq
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, "category", req.Category)
validator.Required(v, "message", req.Message)
validator.MaxLength(v, "message", req.Message, 5000)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
// OptionalAuth seeds user_id when a valid session cookie was
// present; "" means anonymous.
uid := middleware.UserID(r.Context())
var userIDPtr *string
if uid != "" {
userIDPtr = &uid
}
// Soft-validate the contact email for the anonymous path. Authed
// callers' contact_email is ignored downstream, so skip the check.
if userIDPtr == nil && req.ContactEmail != nil && *req.ContactEmail != "" {
ev := validator.New()
validator.Email(ev, "contact_email", *req.ContactEmail)
if !ev.Valid() {
response.Error(w, http.StatusBadRequest, ev.Error())
return
}
}
f, err := h.svc.Submit(r.Context(), userIDPtr, req.Category, req.Message, req.ContactEmail)
if err != nil {
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusCreated, f)
}
+51
View File
@@ -0,0 +1,51 @@
package handler
import (
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// NotificationHandler serves the signed-in user's notification feed +
// unread badge count, and marks them read. All routes sit behind
// RequireAuth, so middleware.UserID is always populated.
type NotificationHandler struct{ svc *service.NotificationService }
func NewNotificationHandler(svc *service.NotificationService) *NotificationHandler {
return &NotificationHandler{svc: svc}
}
// List returns the caller's notifications, newest first.
func (h *NotificationHandler) List(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
items, err := h.svc.List(r.Context(), uid, limit, offset)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load notifications")
return
}
response.JSON(w, http.StatusOK, items)
}
// UnreadCount returns {"count": N} for the bell badge.
func (h *NotificationHandler) UnreadCount(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
n, err := h.svc.UnreadCount(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load unread count")
return
}
response.JSON(w, http.StatusOK, map[string]int{"count": n})
}
// MarkAllRead marks every notification read (called when the page opens).
func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.MarkAllRead(r.Context(), uid); err != nil {
response.Error(w, http.StatusInternalServerError, "could not mark notifications read")
return
}
response.NoContent(w)
}
+309
View File
@@ -0,0 +1,309 @@
package handler
import (
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"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/oauth"
"go.uber.org/zap"
)
// stateCookieName is the short-lived cookie that pairs with the state query
// param so callback can verify "this redirect was started by us." Scoped to
// /api/v1/auth/oauth so it never leaks into other handlers.
const stateCookieName = "spsays_oauth_state"
const stateCookieMaxAge = 10 * 60 // 10 minutes — matches stateMaxAge in pkg/oauth/state.go
// OAuthHandler exposes the GET /api/v1/auth/oauth/{provider}/start and
// /callback endpoints. It composes Provider → token exchange → service.SignInWithOAuth.
type OAuthHandler struct {
svc *service.AuthService
cookies auth.CookieOptions
providers oauth.Registry
stateSecret []byte // HMAC key for signing state — derived from JWT secret
bffPublicURL string // e.g. http://localhost:8080 or https://api.prabhupadasays.org
frontendURL string // e.g. http://localhost:3000 or https://prabhupadasays.org
logger *zap.Logger
}
func NewOAuthHandler(
svc *service.AuthService,
cookies auth.CookieOptions,
providers oauth.Registry,
stateSecret []byte,
bffPublicURL, frontendURL string,
logger *zap.Logger,
) *OAuthHandler {
return &OAuthHandler{
svc: svc,
cookies: cookies,
providers: providers,
stateSecret: stateSecret,
bffPublicURL: strings.TrimRight(bffPublicURL, "/"),
frontendURL: strings.TrimRight(frontendURL, "/"),
logger: logger,
}
}
// Start kicks off the OAuth round-trip. It generates a signed state value,
// stores it in a short-lived cookie, and 302s the browser to the provider's
// AuthCodeURL. `next` query param carries the post-login redirect path on
// the frontend (defaults to "/u/me").
func (h *OAuthHandler) Start(w http.ResponseWriter, r *http.Request) {
providerName := chi.URLParam(r, "provider")
prov, err := h.providers.Get(providerName)
if err != nil {
http.Error(w, "unknown provider", http.StatusNotFound)
return
}
next := sanitizeNext(r.URL.Query().Get("next"))
state, err := oauth.EncodeState(h.stateSecret, providerName, next, "")
if err != nil {
h.logger.Error("oauth encode state", zap.Error(err))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// State cookie matches the auth cookie's SameSite/Secure profile so the
// browser sends it back on the callback redirect. Path-scoped so it
// doesn't ride along on unrelated requests.
http.SetCookie(w, &http.Cookie{
Name: stateCookieName,
Value: state,
Path: "/api/v1/auth/oauth",
Domain: h.cookies.Domain,
MaxAge: stateCookieMaxAge,
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
cfg := prov.Config(h.callbackURL(providerName))
authURL := cfg.AuthCodeURL(state, prov.AuthCodeOptions()...)
http.Redirect(w, r, authURL, http.StatusFound)
}
// Callback finishes the round-trip. Verifies state, exchanges the code,
// fetches the profile, signs the user in, sets the session cookie, and
// redirects back to the frontend `next` path.
func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
providerName := chi.URLParam(r, "provider")
prov, err := h.providers.Get(providerName)
if err != nil {
h.redirectErr(w, r, "/login?oauth_error=unknown_provider")
return
}
// Provider returned an error (user denied, etc.).
if e := r.URL.Query().Get("error"); e != "" {
h.redirectErr(w, r, "/login?oauth_error="+url.QueryEscape(e))
return
}
// State must be present in BOTH cookie and query param, and they must
// match. Either alone is not sufficient (cookie alone → no CSRF binding;
// query alone → an attacker could craft any signed state).
cookie, err := r.Cookie(stateCookieName)
if err != nil {
h.redirectErr(w, r, "/login?oauth_error=missing_state")
return
}
stateQ := r.URL.Query().Get("state")
if stateQ == "" || stateQ != cookie.Value {
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
return
}
// Clear the state cookie regardless of what happens next.
http.SetCookie(w, &http.Cookie{
Name: stateCookieName,
Value: "",
Path: "/api/v1/auth/oauth",
Domain: h.cookies.Domain,
MaxAge: -1,
Expires: time.Unix(0, 0),
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
stateProvider, next, linkUserID, err := oauth.DecodeState(h.stateSecret, stateQ)
if err != nil || stateProvider != providerName {
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
return
}
code := r.URL.Query().Get("code")
if code == "" {
h.redirectErr(w, r, "/login?oauth_error=missing_code")
return
}
cfg := prov.Config(h.callbackURL(providerName))
tok, err := cfg.Exchange(r.Context(), code)
if err != nil {
h.logger.Warn("oauth code exchange failed", zap.String("provider", providerName), zap.Error(err))
h.redirectErr(w, r, "/login?oauth_error=exchange_failed")
return
}
prof, err := prov.FetchProfile(r.Context(), tok)
if err != nil {
h.logger.Warn("oauth fetch profile failed", zap.String("provider", providerName), zap.Error(err))
h.redirectErr(w, r, "/login?oauth_error=profile_failed")
return
}
// State pinned to a user id means the OAuth round-trip was started from
// /link/start (settings page). Dispatch to the link path so we link the
// provider to that user instead of running a fresh sign-in.
if linkUserID != "" {
h.finishLink(w, r, providerName, next, linkUserID, prof)
return
}
res, err := h.svc.SignInWithOAuth(r.Context(), service.OAuthProfile{
Provider: prof.Provider,
ProviderUserID: prof.ProviderUserID,
Email: prof.Email,
EmailVerified: prof.EmailVerified,
Name: prof.Name,
AvatarURL: prof.AvatarURL,
})
if err != nil {
switch {
case errors.Is(err, service.ErrOAuthUnavailable):
h.redirectErr(w, r, "/login?oauth_error=unavailable")
default:
h.logger.Error("oauth sign-in", zap.Error(err))
h.redirectErr(w, r, "/login?oauth_error=server")
}
return
}
auth.Set(w, res.Token, res.Expires, h.cookies)
http.Redirect(w, r, h.frontendURL+next, http.StatusFound)
}
// LinkStart kicks off the OAuth round-trip for an already-authenticated
// user who wants to add a provider to their account (settings → Connect).
// The caller's user id is signed into the state so the callback knows
// which account to link to instead of trying to find one by email.
func (h *OAuthHandler) LinkStart(w http.ResponseWriter, r *http.Request) {
providerName := chi.URLParam(r, "provider")
prov, err := h.providers.Get(providerName)
if err != nil {
http.Error(w, "unknown provider", http.StatusNotFound)
return
}
uid := middleware.UserID(r.Context())
if uid == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next := sanitizeNext(r.URL.Query().Get("next"))
state, err := oauth.EncodeState(h.stateSecret, providerName, next, uid)
if err != nil {
h.logger.Error("oauth encode link state", zap.Error(err))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: stateCookieName,
Value: state,
Path: "/api/v1/auth/oauth",
Domain: h.cookies.Domain,
MaxAge: stateCookieMaxAge,
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
cfg := prov.Config(h.callbackURL(providerName))
authURL := cfg.AuthCodeURL(state, prov.AuthCodeOptions()...)
http.Redirect(w, r, authURL, http.StatusFound)
}
// finishLink completes the link flow once the shared Callback has decoded
// state and fetched the provider profile. Bounces back to the frontend
// `next` path with `?link=ok|claimed|error` so the settings UI can render
// a meaningful message.
func (h *OAuthHandler) finishLink(
w http.ResponseWriter,
r *http.Request,
providerName, next, linkUserID string,
prof *oauth.Profile,
) {
err := h.svc.LinkOAuthToCurrentUser(r.Context(), linkUserID, service.OAuthProfile{
Provider: prof.Provider,
ProviderUserID: prof.ProviderUserID,
Email: prof.Email,
EmailVerified: prof.EmailVerified,
Name: prof.Name,
AvatarURL: prof.AvatarURL,
})
target := next
switch {
case err == nil:
target = appendQuery(next, "link", providerName+":ok")
case errors.Is(err, service.ErrOAuthAccountClaimed):
target = appendQuery(next, "link", providerName+":claimed")
default:
h.logger.Error("oauth link", zap.String("provider", providerName), zap.Error(err))
target = appendQuery(next, "link", providerName+":error")
}
http.Redirect(w, r, h.frontendURL+target, http.StatusFound)
}
// appendQuery adds a ?key=val (or &key=val) to a same-origin path, preserving
// any existing query string. Path must start with "/" — caller guarantees.
func appendQuery(path, key, value string) string {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
return path + sep + url.QueryEscape(key) + "=" + url.QueryEscape(value)
}
// callbackURL composes the redirect_uri the provider's console must allow-list.
func (h *OAuthHandler) callbackURL(provider string) string {
return h.bffPublicURL + "/api/v1/auth/oauth/" + provider + "/callback"
}
// redirectErr sends the user back to the frontend with a single error query
// param so the login page can render something meaningful. No internal details
// leak through this path.
func (h *OAuthHandler) redirectErr(w http.ResponseWriter, r *http.Request, path string) {
http.Redirect(w, r, h.frontendURL+path, http.StatusFound)
}
// sanitizeNext bounds the post-login redirect to same-origin paths. Anything
// containing a scheme, host, or whitespace is dropped to the default. This is
// the same anti-open-redirect treatment we apply on the share-shortener.
func sanitizeNext(raw string) string {
const def = "/u/me"
if raw == "" {
return def
}
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
return def
}
if strings.ContainsAny(raw, " \t\r\n") {
return def
}
if len(raw) > 512 {
return def
}
return raw
}
+101
View File
@@ -0,0 +1,101 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// RelationshipHandler exposes the follow / block / report actions, all
// scoped to a target user identified by {handle} in the path and a
// signed-in viewer pulled from the auth middleware. Every mutating
// endpoint returns the recomputed RelationshipResult (viewer's state +
// the target's live counts) so the frontend updates its buttons and
// stat line from the single response — except Report, which returns 204.
//
// These routes live under /api/v1/u/{handle}/… in a RequireAuth group;
// the public profile GET stays anonymous-friendly in its own group.
type RelationshipHandler struct{ svc *service.SocialGraphService }
func NewRelationshipHandler(svc *service.SocialGraphService) *RelationshipHandler {
return &RelationshipHandler{svc: svc}
}
// Follow sends a follow REQUEST (pending until the target accepts).
func (h *RelationshipHandler) Follow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.RequestFollow)
}
// Unfollow removes the viewer's edge — stops following or cancels a
// still-pending request.
func (h *RelationshipHandler) Unfollow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.Unfollow)
}
// AcceptFollow accepts the target's pending request to follow the viewer.
func (h *RelationshipHandler) AcceptFollow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.AcceptFollow)
}
// RejectFollow declines the target's pending request.
func (h *RelationshipHandler) RejectFollow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.RejectFollow)
}
// RemoveFollower drops the target from the viewer's followers.
func (h *RelationshipHandler) RemoveFollower(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.RemoveFollower)
}
// Block blocks the target (and severs follows both ways).
func (h *RelationshipHandler) Block(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.Block)
}
// Unblock removes the viewer's block on the target.
func (h *RelationshipHandler) Unblock(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.Unblock)
}
type reportReq struct {
Category string `json:"category"`
Reason string `json:"reason"`
}
// Report files a report against the target. Body: {category, reason}.
func (h *RelationshipHandler) Report(w http.ResponseWriter, r *http.Request) {
viewerID := middleware.UserID(r.Context())
handle := chi.URLParam(r, "handle")
var req reportReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.Report(r.Context(), viewerID, handle, req.Category, req.Reason); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// act is the shared shim for every RelationshipResult-returning action:
// pull viewer + handle, run the service method, map errors, return JSON.
func (h *RelationshipHandler) act(
w http.ResponseWriter,
r *http.Request,
fn func(ctx context.Context, viewerID, handle string) (*service.RelationshipResult, error),
) {
viewerID := middleware.UserID(r.Context())
handle := chi.URLParam(r, "handle")
res, err := fn(r.Context(), viewerID, handle)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, res)
}
+44 -10
View File
@@ -147,6 +147,12 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
limit = 100 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() ctx := r.Context()
var results []searchResultItem var results []searchResultItem
@@ -154,13 +160,13 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
if searchType == "all" { if searchType == "all" {
// Search all 9 collections // 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 { } else if len(requestedTypes) == 1 {
// Single collection — use singleSearch for efficiency // 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 { } else {
// Multiple collections — use multiSearch with a subset // 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))) 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, // multiSearch searches across all 9 collections using Typesense multi_search,
// merges results by text_match score, and returns a single paginated list. // merges results by text_match score, and returns a single paginated list.
// multiSearch searches across multiple collections using Typesense multi_search. // multiSearch searches across multiple collections using Typesense multi_search.
// If collections is nil, searches all 9 collections. // If collections is nil, searches all 9 collections. When `exact` is true,
func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int) ([]searchResultItem, int) { // 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 { if collections == nil {
collections = []string{ collections = []string{
"verses", "prose", "letters", "transcriptions", "glossary", "verses", "prose", "letters", "transcriptions", "glossary",
@@ -193,6 +201,7 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
exhaustive := true exhaustive := true
prioritizeExact := true prioritizeExact := true
prioritizeTokenPos := true prioritizeTokenPos := true
prefix, numTypos := fuzzinessKnobs(exact)
searches := make([]api.MultiSearchCollectionParameters, 0, len(collections)) searches := make([]api.MultiSearchCollectionParameters, 0, len(collections))
for _, col := range collections { for _, col := range collections {
queryBy := collectionQueryByFields[col] queryBy := collectionQueryByFields[col]
@@ -205,8 +214,8 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
QueryBy: stringPtr(queryBy), QueryBy: stringPtr(queryBy),
PerPage: &perPage, PerPage: &perPage,
Page: &pg, Page: &pg,
Prefix: stringPtr("true"), Prefix: stringPtr(prefix),
NumTypos: stringPtr("2"), NumTypos: stringPtr(numTypos),
ExhaustiveSearch: &exhaustive, ExhaustiveSearch: &exhaustive,
Stopwords: stringPtr(searchStopwordsSet), Stopwords: stringPtr(searchStopwordsSet),
// Boost docs whose tokens are exact matches and appear early in the field // 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. // 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] queryBy, ok := collectionQueryByFields[collection]
if !ok { if !ok {
return []searchResultItem{}, 0 return []searchResultItem{}, 0
@@ -273,13 +282,14 @@ func (h *SearchHandler) singleSearch(ctx context.Context, collection, query stri
exhaustive := true exhaustive := true
prioritizeExact := true prioritizeExact := true
prioritizeTokenPos := true prioritizeTokenPos := true
prefix, numTypos := fuzzinessKnobs(exact)
params := &api.SearchCollectionParams{ params := &api.SearchCollectionParams{
Q: stringPtr(query), Q: stringPtr(query),
QueryBy: stringPtr(queryBy), QueryBy: stringPtr(queryBy),
Page: &page, Page: &page,
PerPage: &limit, PerPage: &limit,
Prefix: stringPtr("true"), Prefix: stringPtr(prefix),
NumTypos: stringPtr("2"), NumTypos: stringPtr(numTypos),
ExhaustiveSearch: &exhaustive, ExhaustiveSearch: &exhaustive,
Stopwords: stringPtr(searchStopwordsSet), Stopwords: stringPtr(searchStopwordsSet),
// Boost docs whose tokens are exact matches and appear early in the field // 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 { func stringPtr(s string) *string {
return &s 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"
}
+607
View File
@@ -0,0 +1,607 @@
// 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
graph *service.SocialGraphService // post reporting
}
func NewPostHandler(svc *service.PostService, graph *service.SocialGraphService) *PostHandler {
return &PostHandler{svc: svc, graph: graph}
}
type reportPostReq struct {
Category string `json:"category"`
Reason string `json:"reason"`
}
// Report files a report against this post. Body: {category, reason?}.
// Auth-required; the reporter is the signed-in viewer.
func (h *PostHandler) Report(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
var req reportPostReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.graph.ReportPost(r.Context(), uid, id, req.Category, req.Reason); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
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)
}
// Reshare materialises a reshare of the post identified by {id} as a
// new posts row owned by the caller. Idempotent — second call returns
// the same row (still 201). The body is the new (or existing) post so
// the frontend can drop it into its local feed without a refetch.
func (h *PostHandler) Reshare(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
p, err := h.svc.Reshare(r.Context(), id, uid)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusCreated, p)
}
// Unreshare removes a previously recorded reshare. Idempotent.
func (h *PostHandler) Unreshare(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
if err := h.svc.Unreshare(r.Context(), id, uid); err != nil {
mapSocialErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PostHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// viewerID is "" for anonymous viewers — the service treats that as
// "no reshare hydration" so the public-cache shape is unchanged.
p, err := h.svc.Get(r.Context(), id, middleware.UserID(r.Context()))
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
graph *service.SocialGraphService
}
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService) *ProfileHandler {
return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph}
}
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
}
// Follower/following counts + the viewer's relationship to this
// profile. viewerID is "" for anonymous viewers (OptionalAuth) and
// equal to u.ID when the owner hits their own /u/{handle} — both
// cases yield a nil `relationship` so the frontend hides the
// follow/block affordances.
viewerID := middleware.UserID(r.Context())
followers, following, rel, err := h.graph.ProfileExtras(r.Context(), viewerID, u)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load relationship")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"handle": u.Handle,
"name": u.Name,
"avatar_url": u.AvatarURL,
"joined_at": u.CreatedAt,
"country": u.Country,
"post_count": count,
"follower_count": followers,
"following_count": following,
"relationship": rel,
"public_collections": cols,
})
}
// Feed returns posts for the public feed. Same payload as ListMine but
// looked up by handle so it's reachable anonymously. When an
// authenticated viewer hits this route (via OptionalAuth) each post
// carries `viewer_has_reshared` so the action footer renders the right
// button. Anonymous viewers get `false` for every post at zero cost.
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
}
viewerID := middleware.UserID(r.Context())
if viewerID != "" && len(posts) > 0 {
ids := make([]string, 0, len(posts))
for i := range posts {
// Skip own posts — can't reshare yourself, and the swap
// already triggers on isOwner.
if posts[i].UserID != viewerID {
ids = append(ids, posts[i].ID)
}
}
if len(ids) > 0 {
states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids)
if err != nil {
mapSocialErr(w, err)
return
}
for i := range posts {
if states[posts[i].ID] {
posts[i].ViewerHasReshared = true
}
}
}
}
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.ErrBlocked):
response.Error(w, http.StatusForbidden, "this action isn't available between you and this user")
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
}
+190
View File
@@ -0,0 +1,190 @@
package handler
import (
"errors"
"html/template"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
)
// StatsHandler renders a single-page HTML snapshot of the system —
// aggregate counts plus the most-recent feedback rows. Mounted at /stats
// (outside /api/v1, since it returns HTML not JSON) and intentionally
// public per current product call. If that ever needs to change, wrap
// the route in middleware in server.go.
//
// It also handles the "Mark resolved" form POST per feedback row, which
// stamps resolved_at (one-way) and fires the author thank-you email via
// the feedback service.
type StatsHandler struct {
stats *repository.StatsRepo
feedback *repository.FeedbackRepo
svc *service.FeedbackService
}
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo, svc *service.FeedbackService) *StatsHandler {
return &StatsHandler{stats: stats, feedback: feedback, svc: svc}
}
func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
snap, err := h.stats.Snapshot(r.Context())
if err != nil {
http.Error(w, "stats query failed: "+err.Error(), http.StatusInternalServerError)
return
}
recent, err := h.feedback.Recent(r.Context(), 25)
if err != nil {
http.Error(w, "recent feedback query failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Caching off — this is an ops snapshot; staleness defeats the point.
w.Header().Set("Cache-Control", "no-store")
if err := statsTpl.Execute(w, map[string]any{
"Stats": snap,
"Feedback": recent,
"GeneratedAt": time.Now().Format("2006-01-02 15:04:05 MST"),
}); err != nil {
// Headers already flushed — best we can do is bail mid-stream.
return
}
}
// AcknowledgeFeedback handles the per-row "Acknowledge" form POST. Marks
// the row acknowledged (one-way) and emails the author that we've seen
// it and started work, then 303-redirects back to /stats. Already-
// acknowledged or unknown ids redirect silently.
func (h *StatsHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.svc.Acknowledge(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) {
http.Error(w, "acknowledge failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/stats", http.StatusSeeOther)
}
// ResolveFeedback handles the per-row "Mark resolved" form POST. It marks
// the row resolved (one-way) and fires the author thank-you email, then
// 303-redirects back to /stats so a refresh doesn't re-submit. Already-
// resolved or unknown ids redirect silently — the desired end state is
// already true.
func (h *StatsHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.svc.Resolve(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyResolved) {
http.Error(w, "resolve failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/stats", http.StatusSeeOther)
}
var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
}).Parse(statsHTML))
const statsHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Stats · Prabhupāda Says</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; max-width: 920px; margin: 2.5rem auto; padding: 0 1.5rem; color: #1a1a1a; line-height: 1.5 }
h1 { font-size: 1.5rem; margin-bottom: 0.25rem }
h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom: 1px solid #e5e5e5; padding-bottom: 0.25rem }
.meta { color: #888; font-size: 0.8rem; margin-bottom: 1.5rem }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.75rem }
.card { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem }
.card .num { font-size: 1.4rem; font-weight: 600; line-height: 1.1 }
.card .num .delta { font-size: 0.75rem; font-weight: 500; color: #1e7a4a; margin-left: 0.4rem }
.card .label { color: #666; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.35rem }
.row { display: flex; justify-content: space-between; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #f0f0f0; font-size: 0.92rem }
.row:last-child { border-bottom: 0 }
.row .cat { font-weight: 600; text-transform: capitalize }
.fb { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem; margin-bottom: 0.6rem }
.fb.resolved { background: #f3f8f4; border-color: #d6e9da }
.fb .head { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.82rem; color: #666; margin-bottom: 0.4rem; flex-wrap: wrap; align-items: center }
.fb .cat-pill { font-weight: 700; text-transform: capitalize; color: #1a1a1a }
.fb .msg { white-space: pre-wrap; font-size: 0.95rem }
.fb .author { font-size: 0.78rem; color: #888; margin-top: 0.5rem }
.fb .foot { display: flex; justify-content: flex-end; align-items: center; gap: 0.6rem; margin-top: 0.6rem }
.resolved-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #1e7a4a }
.acked-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #9a6b2f }
.resolve-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #fff; background: #1e7a4a; border: 0; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
.resolve-btn:hover { background: #196b40 }
.ack-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #9a6b2f; background: #fff; border: 1px solid #d6b27a; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
.ack-btn:hover { background: #faf5ea }
code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.85em }
.empty { color: #888; font-style: italic }
</style>
</head>
<body>
<h1>System snapshot</h1>
<p class="meta">Generated {{.GeneratedAt}}</p>
<h2>Users &amp; activity</h2>
<div class="grid">
<div class="card"><div class="label">Users</div><div class="num">{{.Stats.Users}}<span class="delta">+{{.Stats.UsersLast7Days}} / 7d</span></div></div>
<div class="card"><div class="label">Posts</div><div class="num">{{.Stats.Posts}}<span class="delta">+{{.Stats.PostsLast7Days}} / 7d</span></div></div>
<div class="card"><div class="label">Collections</div><div class="num">{{.Stats.Collections}}<span class="delta">{{.Stats.PublicCollections}} public</span></div></div>
<div class="card"><div class="label">Bookmarks</div><div class="num">{{.Stats.Bookmarks}}</div></div>
<div class="card"><div class="label">Reshares</div><div class="num">{{.Stats.Reshares}}</div></div>
<div class="card"><div class="label">Feedback</div><div class="num">{{.Stats.Feedback}}<span class="delta">+{{.Stats.FeedbackLast7Days}} / 7d</span></div></div>
</div>
<h2>Feedback by category</h2>
{{if .Stats.FeedbackByCategory}}
{{range .Stats.FeedbackByCategory}}
<div class="row"><span class="cat">{{.Category}}</span><span>{{.Count}}</span></div>
{{end}}
{{else}}
<p class="empty">No feedback yet.</p>
{{end}}
<h2>Recent feedback</h2>
{{if .Feedback}}
{{range .Feedback}}
<div class="fb{{if .ResolvedAt}} resolved{{end}}">
<div class="head">
<span class="cat-pill">{{.Category}}</span>
<span>{{fmtTime .CreatedAt}}</span>
</div>
<div class="msg">{{.Message}}</div>
<div class="author">
{{if .AuthorHandle}}
@{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}}
{{else if .ContactEmail}}
anonymous · <code>{{.ContactEmail}}</code>
{{else}}
anonymous
{{end}}
</div>
<div class="foot">
{{if .ResolvedAt}}
{{if .AcknowledgedAt}}<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>{{end}}
<span class="resolved-badge">✓ Resolved {{fmtTime .ResolvedAt}}</span>
{{else}}
{{if .AcknowledgedAt}}
<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>
{{else}}
<form method="post" action="/stats/feedback/{{.ID}}/acknowledge" onsubmit="return confirm('Acknowledge this feedback? The author will be emailed that we have seen it and started work. This cannot be undone.');">
<button type="submit" class="ack-btn">Acknowledge</button>
</form>
{{end}}
<form method="post" action="/stats/feedback/{{.ID}}/resolve" onsubmit="return confirm('Mark this feedback as resolved? This cannot be undone, and the author will be emailed.');">
<button type="submit" class="resolve-btn">Mark resolved</button>
</form>
{{end}}
</div>
</div>
{{end}}
{{else}}
<p class="empty">No feedback yet.</p>
{{end}}
</body>
</html>`
+214
View File
@@ -0,0 +1,214 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"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"`
Country *string `json:"country"`
// Devotional profile (user-editable).
Sampradaya *string `json:"sampradaya"`
Gender *string `json:"gender"`
Initiated *bool `json:"initiated"`
InitiatedAt *string `json:"initiated_at"`
DailyChantingRounds *int `json:"daily_chanting_rounds"`
// System-managed notification fields — accepted here so future logic
// can set them via the same endpoint; no UI sends them today.
PreferredChantingTime *string `json:"preferred_chanting_time"`
PeakActiveTime *string `json:"peak_active_time"`
}
// UpdateProfile patches any subset of the editable profile fields.
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, service.UpdateProfileInput{
Name: req.Name,
AvatarURL: req.AvatarURL,
Handle: req.Handle,
Country: req.Country,
Sampradaya: req.Sampradaya,
Gender: req.Gender,
Initiated: req.Initiated,
InitiatedAt: req.InitiatedAt,
DailyChantingRounds: req.DailyChantingRounds,
PreferredChantingTime: req.PreferredChantingTime,
PeakActiveTime: req.PeakActiveTime,
})
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)
}
// userSearchItem is the public-safe slice of model.User returned by the
// search endpoint. Sensitive fields (email, password hash, role, plan,
// token_version, timestamps) are intentionally omitted.
type userSearchItem struct {
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url,omitempty"`
}
// Search handles GET /api/v1/users/search?q=...&limit=...
//
// Public endpoint — anonymous and signed-in viewers alike can search
// for users by handle or display name. Returns a sanitized payload
// without auth-sensitive fields. Used by the global /search page when
// the query carries an "@" prefix.
func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
response.JSON(w, http.StatusOK, map[string]any{
"query": "",
"results": []userSearchItem{},
})
return
}
limit := 20
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
limit = n
}
}
users, err := h.svc.Search(r.Context(), q, limit)
if err != nil {
response.Error(w, http.StatusInternalServerError, "search failed")
return
}
items := make([]userSearchItem, 0, len(users))
for _, u := range users {
items = append(items, userSearchItem{
Handle: u.Handle,
Name: u.Name,
AvatarURL: u.AvatarURL,
})
}
response.JSON(w, http.StatusOK, map[string]any{
"query": q,
"results": items,
})
}
// GetReadingPrefs returns the caller's saved reading prefs blob (always
// well-formed JSON; an empty object when the user has never written).
// The frontend treats the response as the source of truth on first
// sign-in for this device.
func (h *UserHandler) GetReadingPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
raw, err := h.svc.GetReadingPrefs(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load reading prefs")
return
}
// Round-trip the JSONB as the data field directly. We use a raw
// json.RawMessage so the body is `{"data": {...}}` not a re-encoded
// string. The envelope wrapper keeps shape parity with the rest of
// the API.
response.JSON(w, http.StatusOK, json.RawMessage(raw))
}
// UpdateReadingPrefs replaces the caller's reading-prefs blob with the
// request body (which must itself be a JSON object — we re-marshal a
// decoded map to enforce that without imposing a fixed schema on a
// payload the frontend owns).
func (h *UserHandler) UpdateReadingPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
// Decode into map[string]any to guarantee the body is an object (not
// an array, number, or string). Re-marshal before persisting so the
// stored JSON is canonicalised and any duplicate keys are dropped.
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body (expect an object)")
return
}
prefsJSON, err := json.Marshal(body)
if err != nil {
response.Error(w, http.StatusBadRequest, "could not re-encode prefs")
return
}
if err := h.svc.UpdateReadingPrefs(r.Context(), uid, prefsJSON); err != nil {
response.Error(w, http.StatusInternalServerError, "could not save reading prefs")
return
}
response.JSON(w, http.StatusOK, json.RawMessage(prefsJSON))
}
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})
}
+81 -26
View File
@@ -2,56 +2,111 @@ package middleware
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/response" "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. // RequireAuth is the middleware applied to every protected route.
// It extracts the Bearer token from the Authorization header, //
// validates it, and sets the user ID in the request context. // It pulls the JWT from the cookie, verifies signature + expiry, then asks
func Auth(jwtSecret string) func(next http.Handler) http.Handler { // 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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization") raw := auth.Read(r)
if authHeader == "" { if raw == "" {
response.Error(w, http.StatusUnauthorized, "missing authorization header") response.Error(w, http.StatusUnauthorized, "not authenticated")
return return
} }
parts := strings.SplitN(authHeader, " ", 2) claims, err := issuer.Parse(raw)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { if err != nil {
response.Error(w, http.StatusUnauthorized, "invalid authorization format") response.Error(w, http.StatusUnauthorized, "invalid or expired session")
return return
} }
token := parts[1] u, err := users.FindByID(r.Context(), claims.UserID)
if err != nil {
// TODO: validate JWT token and extract user ID if errors.Is(err, repository.ErrUserNotFound) {
_ = token response.Error(w, http.StatusUnauthorized, "account no longer exists")
_ = jwtSecret return
userID := "" // placeholder }
response.Error(w, http.StatusInternalServerError, "auth check failed")
if userID == "" { return
response.Error(w, http.StatusUnauthorized, "invalid or expired token") }
if u.TokenVersion != claims.TokenVersion {
response.Error(w, http.StatusUnauthorized, "session expired — please log in again")
return 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)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
} }
// GetUserID extracts the user ID from the request context. // OptionalAuth is the soft-auth sibling of RequireAuth. Same logic, but
func GetUserID(ctx context.Context) string { // invalid / missing sessions don't reject the request — they just skip
if v, ok := ctx.Value(UserIDKey).(string); ok { // the context population. Use it on routes that are reachable
// anonymously but want to personalise the response when the viewer
// happens to be signed in (e.g. `viewer_has_reshared` on a public
// post).
func OptionalAuth(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) {
raw := auth.Read(r)
if raw == "" {
next.ServeHTTP(w, r)
return
}
claims, err := issuer.Parse(raw)
if err != nil {
next.ServeHTTP(w, r)
return
}
u, err := users.FindByID(r.Context(), claims.UserID)
if err != nil || u.TokenVersion != claims.TokenVersion {
// Stale or invalid session — proceed anonymously rather
// than failing the request. RequireAuth would 401 here.
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// 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 v
} }
return "" 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
}
+37
View File
@@ -0,0 +1,37 @@
package model
import "time"
// Feedback is a user-submitted message — bug report, feature request,
// or general comment — captured by the /feedback page. Surfaced to
// admins both via the /stats page and via an email fan-out on insert.
//
// UserID is a pointer so the model can carry anonymous submissions
// (logged-out visitors) and orphaned rows (the submitter has since
// deleted their account — the FK is set null). ContactEmail is the
// optional reply-to filled in by anonymous submitters.
//
// AuthorHandle / AuthorName / AuthorEmail are populated by joins in
// the repo's lookup helpers (used by the admin email template and the
// /stats page). They're nil for anonymous rows.
type Feedback struct {
ID string `json:"id"`
UserID *string `json:"user_id,omitempty"`
Category string `json:"category"`
Message string `json:"message"`
ContactEmail *string `json:"contact_email,omitempty"`
CreatedAt time.Time `json:"created_at"`
// AcknowledgedAt is set when an admin presses "Acknowledge" on the
// /stats page — the team has seen it and (if it needs work) the devs
// have started. NULL until then. One-way like ResolvedAt.
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
// ResolvedAt is set the moment an admin presses "Mark resolved" on
// the /stats page. NULL while open. The action is one-way — the
// repo's MarkResolved guards on `resolved_at IS NULL` so a second
// resolve is a no-op.
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
AuthorHandle *string `json:"author_handle,omitempty"`
AuthorName *string `json:"author_name,omitempty"`
AuthorEmail *string `json:"-"` // server-side only; never serialised
}
+173
View File
@@ -0,0 +1,173 @@
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.
//
// Reshares are real Post rows owned by the resharer, with
// ResharedFromPostID pointing to the canonical original. Likes, comments,
// and images all hang off the post's own id, so they diverge naturally
// across reshare boundaries — no parallel "per-resharer" tables.
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"`
// Reshare attribution. ResharedFromPostID is non-nil when this post
// is a reshare; the four `Original*` fields carry the canonical
// post's author info so the frontend can render a
// "Reshared from @handle" link without an extra round-trip.
ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"`
OriginalAuthorName *string `json:"original_author_name,omitempty"`
OriginalAuthorHandle *string `json:"original_author_handle,omitempty"`
OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"`
// ViewerHasReshared is true when the calling user has reshared the
// canonical version of this post. Hydrated by the service layer
// after the repo returns posts; always false for anonymous viewers.
// Drives the post-card "Reshare ↔ Send" swap on the frontend.
ViewerHasReshared bool `json:"viewer_has_reshared"`
}
// 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
}
// RelationshipState is the viewer's relationship to a target profile,
// computed fresh per request from the follows + blocks tables. Every
// field is "from the viewer looking at the target":
//
// - Following: viewer → target follow edge is accepted.
// - FollowRequested: viewer → target follow edge is pending (awaiting
// the target's approval). The "Requested" button state.
// - FollowsYou: target → viewer follow edge is accepted (they
// already follow the viewer). Drives the "Follow back" label and
// the "Remove from followers" menu item.
// - RequestsYou: target → viewer follow edge is pending (the target
// asked to follow the viewer). Drives the Accept / Reject buttons.
// - Blocked: viewer has blocked the target.
// - BlockedBy: target has blocked the viewer.
//
// nil (not this struct's zero value) is returned for anonymous viewers
// and for a user viewing their own profile — callers special-case those.
type RelationshipState struct {
Following bool `json:"following"`
FollowRequested bool `json:"follow_requested"`
FollowsYou bool `json:"follows_you"`
RequestsYou bool `json:"requests_you"`
Blocked bool `json:"blocked"`
BlockedBy bool `json:"blocked_by"`
}
// Follow is one row of the follows table — a directed, request-based
// edge from Follower to Following. Status is "pending" until the target
// accepts.
type Follow struct {
ID string `json:"id"`
FollowerID string `json:"follower_id"`
FollowingID string `json:"following_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
RespondedAt *time.Time `json:"responded_at,omitempty"`
}
// UserBlock is one row of user_blocks — Blocker has blocked Blocked.
type UserBlock struct {
ID string `json:"id"`
BlockerID string `json:"blocker_id"`
BlockedID string `json:"blocked_id"`
CreatedAt time.Time `json:"created_at"`
}
// UserReport is one row of user_reports — Reporter flagged Reported.
type UserReport struct {
ID string `json:"id"`
ReporterID string `json:"reporter_id"`
ReportedID string `json:"reported_id"`
Category string `json:"category"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
}
// PostReport is one row of post_reports — Reporter flagged a specific
// post. Reason is optional ("" when none given).
type PostReport struct {
ID string `json:"id"`
ReporterID string `json:"reporter_id"`
PostID string `json:"post_id"`
Category string `json:"category"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
}
// Notification is one delivered/queued notification for a recipient
// (UserID). ActorID is who triggered it; Kind is a short machine string.
// Hydrated Actor* fields are filled by repo joins for list rendering.
type Notification struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ActorID *string `json:"actor_id,omitempty"`
Kind string `json:"kind"`
CreatedAt time.Time `json:"created_at"`
ReadAt *time.Time `json:"read_at,omitempty"`
ActorName *string `json:"actor_name,omitempty"`
ActorHandle *string `json:"actor_handle,omitempty"`
ActorAvatar *string `json:"actor_avatar_url,omitempty"`
}
+103 -38
View File
@@ -1,51 +1,116 @@
package model package model
import "time" import (
"encoding/json"
"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. It's a
// pointer because OAuth-only users have no password — *PasswordHash == nil
// means "this account can only sign in via a linked OAuth provider".
//
// 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 { type User struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` // Email is nullable: OAuth users (typically Facebook accounts with no
Name string `json:"name"` // verified email on file) sign up without one and can fill it in later
Avatar *string `json:"avatar,omitempty"` // from their profile.
Role string `json:"role"` // "user" | "admin" | "moderator" Email *string `json:"email"`
CreatedAt time.Time `json:"created_at"` Handle string `json:"handle"`
UpdatedAt time.Time `json:"updated_at"` Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
Role string `json:"role"`
Plan string `json:"plan"` // "free" | "premium"
// Country is the account's ISO-3166-1 alpha-2 code (e.g. "IN"),
// captured best-effort at signup and editable from the profile form.
// nil until known.
Country *string `json:"country"`
// --- Devotional profile (user-editable) ---
// Gender is a free-but-constrained string ("male"|"female"|"other"),
// nil when unspecified.
Gender *string `json:"gender"`
// Sampradaya is the devotee's spiritual lineage (a lowercase code,
// e.g. "gaudiya"). Optional — new accounts default to "gaudiya" but it
// can be cleared, so nil means "not specified".
Sampradaya *string `json:"sampradaya"`
// Initiated marks whether the devotee has received initiation.
Initiated bool `json:"initiated"`
// InitiatedAt is the initiation date (DATE column), nil when unknown or
// not initiated.
InitiatedAt *time.Time `json:"initiated_at"`
// DailyChantingRounds is the devotee's daily japa round vow (1–1008),
// nil when unspecified.
DailyChantingRounds *int `json:"daily_chanting_rounds"`
// --- Notification scheduling (system-managed; no UI yet) ---
// PreferredChantingTime / PeakActiveTime store a local clock time as
// "HH:MM". They're written silently by the backend to time automated
// notifications; the scheduling logic ships later. nil until computed.
PreferredChantingTime *string `json:"preferred_chanting_time"`
PeakActiveTime *string `json:"peak_active_time"`
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. // MarshalJSON adds a derived `has_password` flag so the frontend can pick
type Bookmark struct { // "Change password" vs "Set a password" for OAuth-only accounts, without
ID string `json:"id"` // the password hash itself ever leaving the server.
UserID string `json:"user_id"` func (u User) MarshalJSON() ([]byte, error) {
ContentType string `json:"content_type"` // "verse" | "letter" | "essay" | "transcription" | etc. type alias User
ContentID string `json:"content_id"` return json.Marshal(&struct {
ContentRef string `json:"content_ref"` // human-readable reference (e.g., "BG 1.1") alias
SelectedText *string `json:"selected_text,omitempty"` HasPassword bool `json:"has_password"`
Note *string `json:"note,omitempty"` }{
CreatedAt time.Time `json:"created_at"` alias: alias(u),
HasPassword: u.PasswordHash != nil,
})
} }
// Collection represents a user-created collection. // AuthToken represents a row in the `auth_tokens` table.
type Collection struct { // Used for both password reset and email verification flows.
ID string `json:"id"` type AuthToken struct {
UserID string `json:"user_id"` ID string `json:"id"`
Name string `json:"name"` UserID string `json:"user_id"`
Description *string `json:"description,omitempty"` Purpose string `json:"purpose"` // "password_reset" | "email_verification"
IsPublic bool `json:"is_public"` TokenHash string `json:"-"`
ItemCount int `json:"item_count"` ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"` UsedAt *time.Time `json:"used_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
} }
// Mention represents a mention of content shared by a user. const (
type Mention struct { PurposePasswordReset = "password_reset"
ID string `json:"id"` PurposeEmailVerification = "email_verification"
UserID string `json:"user_id"` // PurposeLoginOTP is a 6-digit one-time code emailed to a user whose
ContentType string `json:"content_type"` // account has been unverified for >30 days. Verification happens
ContentID string `json:"content_id"` // per-user (FindValidForUser), so the 1M-keyspace can't be brute-forced
ContentRef string `json:"content_ref"` // offline by hash collision — only online against rate limits.
Text string `json:"text"` PurposeLoginOTP = "login_otp"
CreatedAt time.Time `json:"created_at"` )
// OAuthAccount represents a row in the `oauth_accounts` table — one entry
// per (provider, provider_user_id). A single user can have several linked
// providers.
type OAuthAccount struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Provider string `json:"provider"`
ProviderUserID string `json:"provider_user_id"`
Email *string `json:"email"`
Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
// ShortURL represents a shortened URL. // ShortURL represents a shortened URL.
+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
}
+264
View File
@@ -0,0 +1,264 @@
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 inside a collection, ordered by collection
// position then added_at. Images are NOT hydrated — call
// PostRepo.ImagesFor on the returned ids if your caller renders the
// post visually. The CollectionService.Get path already does this.
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)
}
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()
}
+119
View File
@@ -0,0 +1,119 @@
package repository
import (
"context"
"fmt"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// FeedbackRepo wraps Postgres access for the feedback table.
type FeedbackRepo struct{ pg *Postgres }
func NewFeedbackRepo(pg *Postgres) *FeedbackRepo { return &FeedbackRepo{pg: pg} }
// Create inserts a single feedback row. userID is a pointer so the
// authed and anonymous paths share one method; contactEmail is also a
// pointer because authed submissions don't carry one (we read the
// submitter's email from the joined user row at notify time).
func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
f := &model.Feedback{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO feedback (user_id, category, message, contact_email)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, category, message, contact_email, created_at`,
userID, category, message, contactEmail,
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt)
if err != nil {
return nil, fmt.Errorf("insert feedback: %w", err)
}
return f, nil
}
// FindByID returns one feedback row joined with the submitter's
// handle + name + email if a user_id is present. Used after Create to
// assemble the admin notification email with the proper author block,
// and after MarkResolved to assemble the author thank-you email.
func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) {
f := &model.Feedback{}
err := r.pg.Pool.QueryRow(ctx, `
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
f.acknowledged_at, f.resolved_at,
u.handle, u.name, u.email
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
WHERE f.id = $1`, id,
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
if err != nil {
return nil, fmt.Errorf("find feedback: %w", err)
}
return f, nil
}
// MarkAcknowledged stamps acknowledged_at = now() on a not-yet-
// acknowledged row. The `acknowledged_at IS NULL` guard makes this
// idempotent and one-way: first call returns true (newly acknowledged →
// fire the email), any later call — or an unknown id — returns false.
func (r *FeedbackRepo) MarkAcknowledged(ctx context.Context, id string) (acked bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE feedback
SET acknowledged_at = now()
WHERE id = $1 AND acknowledged_at IS NULL`, id,
)
if err != nil {
return false, fmt.Errorf("mark feedback acknowledged: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// MarkResolved stamps resolved_at = now() on a still-open row. The
// `resolved_at IS NULL` guard makes this idempotent and one-way: the
// first call returns true (newly resolved), any subsequent call — or a
// call on an unknown id — returns false so the caller knows NOT to fire
// the thank-you email a second time.
func (r *FeedbackRepo) MarkResolved(ctx context.Context, id string) (resolved bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE feedback
SET resolved_at = now()
WHERE id = $1 AND resolved_at IS NULL`, id,
)
if err != nil {
return false, fmt.Errorf("mark feedback resolved: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// Recent returns the most-recent N feedback rows joined with users
// for display on /stats. NULL author columns mean anonymous (or the
// original submitter's account was since deleted).
func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback, error) {
if limit <= 0 || limit > 200 {
limit = 25
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
f.acknowledged_at, f.resolved_at,
u.handle, u.name
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
ORDER BY f.created_at DESC
LIMIT $1`, limit,
)
if err != nil {
return nil, fmt.Errorf("query recent feedback: %w", err)
}
defer rows.Close()
out := []model.Feedback{}
for rows.Next() {
var f model.Feedback
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName); err != nil {
return nil, fmt.Errorf("scan feedback: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
+94
View File
@@ -0,0 +1,94 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// ErrOAuthAccountNotFound mirrors the other repo sentinels.
var ErrOAuthAccountNotFound = errors.New("oauth account not found")
// OAuthAccountRepo manages provider linkage rows.
type OAuthAccountRepo struct {
pg *Postgres
}
func NewOAuthAccountRepo(pg *Postgres) *OAuthAccountRepo {
return &OAuthAccountRepo{pg: pg}
}
// FindByProvider returns the linkage row for the given (provider, provider_user_id),
// or ErrOAuthAccountNotFound. Use this first on every callback — a hit means
// this provider identity has been seen before and we just log the user in.
func (r *OAuthAccountRepo) FindByProvider(ctx context.Context, provider, providerUserID string) (*model.OAuthAccount, error) {
const q = `
SELECT id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at
FROM oauth_accounts
WHERE provider = $1 AND provider_user_id = $2
LIMIT 1`
a := &model.OAuthAccount{}
err := r.pg.Pool.QueryRow(ctx, q, provider, providerUserID).Scan(
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrOAuthAccountNotFound
}
return nil, fmt.Errorf("find oauth account: %w", err)
}
return a, nil
}
// ListProvidersForUser returns the distinct provider names linked to this
// user, ordered alphabetically. Used by the settings UI to mark each
// social-connect row as "Connected" vs "Connect".
func (r *OAuthAccountRepo) ListProvidersForUser(ctx context.Context, userID string) ([]string, error) {
const q = `
SELECT DISTINCT provider
FROM oauth_accounts
WHERE user_id = $1
ORDER BY provider`
rows, err := r.pg.Pool.Query(ctx, q, userID)
if err != nil {
return nil, fmt.Errorf("list oauth providers: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, fmt.Errorf("scan oauth provider: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
// Link inserts a new oauth_accounts row tying a provider identity to an
// existing user. Idempotent: a second call for the same (provider, sub)
// returns the existing row instead of erroring on the unique constraint.
func (r *OAuthAccountRepo) Link(ctx context.Context, userID, provider, providerUserID string, email, name, avatarURL *string) (*model.OAuthAccount, error) {
const q = `
INSERT INTO oauth_accounts (user_id, provider, provider_user_id, email, name, avatar_url)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (provider, provider_user_id) DO UPDATE
SET email = EXCLUDED.email,
name = EXCLUDED.name,
avatar_url = EXCLUDED.avatar_url,
updated_at = now()
RETURNING id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at`
a := &model.OAuthAccount{}
err := r.pg.Pool.QueryRow(ctx, q, userID, provider, providerUserID, email, name, avatarURL).Scan(
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("link oauth account: %w", err)
}
return a, nil
}
+430
View File
@@ -0,0 +1,430 @@
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.
//
// Reshare model: a reshare is a real `posts` row owned by the resharer
// with `reshared_from_post_id` pointing at the canonical original. Likes
// and comments (future) will hang off post.id, so they diverge between
// original and reshare for free. Backgrounds are per-row in post_images,
// same story. The only thing the original and its reshares share is the
// caption / source_url / source_title at the moment of reshare.
type PostRepo struct{ pg *Postgres }
func NewPostRepo(pg *Postgres) *PostRepo { return &PostRepo{pg: pg} }
// postSelectColumns is the canonical column list used by every reader so
// every Post payload carries the same shape — including reshare
// attribution. The LEFT JOINs to `original_post` + `original_user` are
// always present; for canonical (non-reshare) rows they just yield NULL.
const postSelectColumns = `
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,
p.reshared_from_post_id,
ou.name AS original_author_name,
ou.handle AS original_author_handle,
ou.avatar_url AS original_author_avatar
`
const postSelectFrom = `
FROM posts p
JOIN users u ON u.id = p.user_id
LEFT JOIN posts op ON op.id = p.reshared_from_post_id
LEFT JOIN users ou ON ou.id = op.user_id
`
// scanPost decodes one row produced by `postSelectColumns` into the
// destination Post. Single source of truth for the column → struct
// mapping, used by every reader.
func scanPost(row interface {
Scan(dest ...any) error
}, p *model.Post) error {
return row.Scan(
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
&p.ResharedFromPostID,
&p.OriginalAuthorName, &p.OriginalAuthorHandle, &p.OriginalAuthorAvatar,
)
}
// 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. Owner-only — every
// reshare is its own posts row now, so the resharer is the owner of
// their reshare and edits it just like any other post they made.
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 (plus original
// author info if this row is a reshare).
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1`
p := &model.Post{}
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); 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 profile feed for a user. Unknown handle →
// empty page (matches pre-existing behaviour).
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
var userID string
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return []model.Post{}, 0, nil
}
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
}
return r.ListByUser(ctx, userID, limit, offset)
}
// ListByUser returns the user's profile feed — every post they own,
// authored or reshared. Reshares are interleaved naturally because
// they're stored as real rows; sort by created_at gets the right order
// without any UNION gymnastics.
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
total, err := r.CountByUser(ctx, userID)
if err != nil {
return nil, 0, err
}
q := `SELECT ` + postSelectColumns + postSelectFrom + `
WHERE p.user_id = $1
ORDER BY p.created_at DESC, p.id
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset)
}
// Delete removes a post only if it belongs to the caller. Cascades:
// - the post's images (post_images.post_id FK)
// - any reshares of this post made by other users (posts.reshared_from_post_id FK)
// - those reshares' images (post_images.post_id FK on each reshare)
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 returns the user's total post count (authored + reshared).
// Now just a single COUNT against posts.user_id.
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 posts: %w", err)
}
return n, nil
}
// Reshare creates a new posts row owned by `resharerID` that points at
// the canonical original via `reshared_from_post_id`. Copies the
// original's caption / source_url / source_title and its current images
// so the resharer's view renders identically at the moment of reshare;
// after that the two diverge — edits to either side never touch the
// other.
//
// If the post being reshared is itself a reshare we point at *its*
// canonical (transitive ancestor), so every reshare always references
// the true original.
//
// Idempotent: a user already resharing a canonical post returns the
// existing reshare row instead of creating a second one.
//
// Service-layer rule (caller can't reshare their own canonical) is
// enforced above this method.
func (r *PostRepo) Reshare(ctx context.Context, originalPostID, resharerID 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) }()
// Load the post being reshared. Walk one level back to canonical
// in case the caller is resharing someone else's reshare.
var (
canonicalID string
caption string
sourceURL string
sourceTitle string
canonicalUser string
)
if err := tx.QueryRow(ctx, `
SELECT
COALESCE(p.reshared_from_post_id, p.id),
p.caption, p.source_url, COALESCE(p.source_title, ''),
COALESCE(op.user_id, p.user_id)
FROM posts p
LEFT JOIN posts op ON op.id = p.reshared_from_post_id
WHERE p.id = $1`, originalPostID,
).Scan(&canonicalID, &caption, &sourceURL, &sourceTitle, &canonicalUser); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPostNotFound
}
return nil, fmt.Errorf("load canonical: %w", err)
}
if canonicalUser == resharerID {
return nil, errors.New("cannot reshare your own post")
}
// Idempotency: existing reshare by this user of this canonical?
var existingID string
switch err := tx.QueryRow(ctx, `
SELECT id FROM posts
WHERE user_id = $1 AND reshared_from_post_id = $2
LIMIT 1`, resharerID, canonicalID,
).Scan(&existingID); {
case err == nil:
// Already exists — commit the no-op tx and return the existing row.
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return r.FindByID(ctx, existingID)
case errors.Is(err, pgx.ErrNoRows):
// Fresh reshare path below.
default:
return nil, fmt.Errorf("check existing reshare: %w", err)
}
var newID string
if err := tx.QueryRow(ctx, `
INSERT INTO posts (user_id, caption, source_url, source_title, reshared_from_post_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
resharerID, caption, sourceURL, sourceTitle, canonicalID,
).Scan(&newID); err != nil {
return nil, fmt.Errorf("insert reshare post: %w", err)
}
// Copy the canonical's current images as the resharer's starting set.
if _, err := tx.Exec(ctx, `
INSERT INTO post_images (post_id, url, position)
SELECT $1, url, position
FROM post_images
WHERE post_id = $2`, newID, canonicalID,
); err != nil {
return nil, fmt.Errorf("copy images: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return r.FindByID(ctx, newID)
}
// Unreshare removes the caller's reshare of a canonical post. The
// caller passes the canonical post id (what the UI knows); we look up
// the caller's reshare-row and delete it. Idempotent — no reshare to
// delete is a no-op.
func (r *PostRepo) Unreshare(ctx context.Context, canonicalPostID, resharerID string) error {
if _, err := r.pg.Pool.Exec(ctx, `
DELETE FROM posts
WHERE user_id = $1 AND reshared_from_post_id = $2`,
resharerID, canonicalPostID,
); err != nil {
return fmt.Errorf("unreshare: %w", err)
}
return nil
}
// ReshareStatesFor returns the set of post IDs from the supplied list
// that the viewer has reshared. Used by feed renderers to flip the
// post-card "Reshare ↔ Send" toggle.
//
// `postIDs` may carry either canonical posts or reshare rows; the query
// resolves each id to its canonical first, then asks "does the viewer
// have a row whose reshared_from_post_id equals that canonical?". The
// returned map keys are the supplied ids — callers don't need to know
// the canonical relationship.
func (r *PostRepo) ReshareStatesFor(ctx context.Context, viewerID string, postIDs []string) (map[string]bool, error) {
out := make(map[string]bool, len(postIDs))
if viewerID == "" || len(postIDs) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT p.id
FROM posts p
WHERE p.id = ANY($2)
AND EXISTS (
SELECT 1 FROM posts r
WHERE r.user_id = $1
AND r.reshared_from_post_id = COALESCE(p.reshared_from_post_id, p.id)
)`, viewerID, postIDs,
)
if err != nil {
return nil, fmt.Errorf("query reshare states: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan reshare state: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
// --- 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 := scanPost(rows, &p); 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. Exported because the
// CollectionService needs to hydrate images onto posts that come back
// from CollectionRepo.PostsIn (which only joins posts/users — not
// post_images — to keep that query focused).
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
}
+308
View File
@@ -0,0 +1,308 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// social_graph_repo.go bundles the four small repos backing the social
// graph — follows, blocks, reports, and notifications. Each is a thin
// wrapper over the shared Postgres pool, mirroring the per-domain repo
// convention used elsewhere (UserRepo, PostRepo, …). They're grouped in
// one file because each is only a handful of queries and they share the
// same dependency.
// ---------------------------------------------------------------------------
// FollowRepo
// ---------------------------------------------------------------------------
// FollowRepo manages the request-based follows edge table.
type FollowRepo struct{ pg *Postgres }
func NewFollowRepo(pg *Postgres) *FollowRepo { return &FollowRepo{pg: pg} }
// Request inserts a pending follow edge follower→following. Idempotent:
// a repeated request (the edge already exists, pending or accepted) is a
// no-op. Returns created=true only when a brand-new pending row was
// inserted, so the caller fires the "X requested to follow you"
// notification exactly once.
func (r *FollowRepo) Request(ctx context.Context, followerID, followingID string) (created bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO follows (follower_id, following_id, status)
VALUES ($1, $2, 'pending')
ON CONFLICT (follower_id, following_id) DO NOTHING`,
followerID, followingID,
)
if err != nil {
return false, fmt.Errorf("request follow: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// Accept flips a pending edge follower→following to accepted and stamps
// responded_at. Returns accepted=true only on the transition from
// pending (so the "X accepted your request" notification fires once); a
// call on an already-accepted or absent edge returns false.
func (r *FollowRepo) Accept(ctx context.Context, followerID, followingID string) (accepted bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE follows
SET status = 'accepted', responded_at = now()
WHERE follower_id = $1 AND following_id = $2 AND status = 'pending'`,
followerID, followingID,
)
if err != nil {
return false, fmt.Errorf("accept follow: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// Delete removes the edge follower→following regardless of status. Backs
// unfollow (accepted), cancel-request (own pending outgoing), reject
// (their pending incoming), and remove-follower (their accepted edge to
// me). Idempotent.
func (r *FollowRepo) Delete(ctx context.Context, followerID, followingID string) error {
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM follows WHERE follower_id = $1 AND following_id = $2`,
followerID, followingID,
)
if err != nil {
return fmt.Errorf("delete follow: %w", err)
}
return nil
}
// Status returns the status of the edge follower→following ('pending' or
// 'accepted'), or "" when no edge exists.
func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string) (string, error) {
var status string
err := r.pg.Pool.QueryRow(ctx, `
SELECT status FROM follows WHERE follower_id = $1 AND following_id = $2`,
followerID, followingID,
).Scan(&status)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return "", fmt.Errorf("follow status: %w", err)
}
return status, nil
}
// CountFollowers counts accepted edges pointing at userID.
func (r *FollowRepo) CountFollowers(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM follows WHERE following_id = $1 AND status = 'accepted'`,
userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count followers: %w", err)
}
return n, nil
}
// CountFollowing counts accepted edges originating from userID.
func (r *FollowRepo) CountFollowing(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM follows WHERE follower_id = $1 AND status = 'accepted'`,
userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count following: %w", err)
}
return n, nil
}
// ---------------------------------------------------------------------------
// BlockRepo
// ---------------------------------------------------------------------------
// BlockRepo manages user_blocks. Block() also tears down any follow
// edges in either direction, so it runs in a transaction.
type BlockRepo struct{ pg *Postgres }
func NewBlockRepo(pg *Postgres) *BlockRepo { return &BlockRepo{pg: pg} }
// Block records blocker→blocked (idempotent) and, in the same tx, deletes
// any follow edges between the two in either direction — blocking someone
// should sever the relationship both ways.
func (r *BlockRepo) Block(ctx context.Context, blockerID, blockedID string) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin block tx: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
INSERT INTO user_blocks (blocker_id, blocked_id)
VALUES ($1, $2)
ON CONFLICT (blocker_id, blocked_id) DO NOTHING`,
blockerID, blockedID,
); err != nil {
return fmt.Errorf("insert block: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM follows
WHERE (follower_id = $1 AND following_id = $2)
OR (follower_id = $2 AND following_id = $1)`,
blockerID, blockedID,
); err != nil {
return fmt.Errorf("sever follows on block: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit block: %w", err)
}
return nil
}
// Unblock removes the blocker→blocked record. Idempotent.
func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) error {
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2`,
blockerID, blockedID,
)
if err != nil {
return fmt.Errorf("unblock: %w", err)
}
return nil
}
// BlockState reports, in one round-trip, whether a blocked b and whether
// b blocked a.
func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) {
err = r.pg.Pool.QueryRow(ctx, `
SELECT
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2),
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $2 AND blocked_id = $1)`,
a, b,
).Scan(&aBlockedB, &bBlockedA)
if err != nil {
return false, false, fmt.Errorf("block state: %w", err)
}
return aBlockedB, bBlockedA, nil
}
// ---------------------------------------------------------------------------
// ReportRepo
// ---------------------------------------------------------------------------
// ReportRepo manages user_reports.
type ReportRepo struct{ pg *Postgres }
func NewReportRepo(pg *Postgres) *ReportRepo { return &ReportRepo{pg: pg} }
// Create inserts a user-report row.
func (r *ReportRepo) Create(ctx context.Context, reporterID, reportedID, category, reason string) (*model.UserReport, error) {
rep := &model.UserReport{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO user_reports (reporter_id, reported_id, category, reason)
VALUES ($1, $2, $3, $4)
RETURNING id, reporter_id, reported_id, category, reason, created_at, resolved_at`,
reporterID, reportedID, category, reason,
).Scan(&rep.ID, &rep.ReporterID, &rep.ReportedID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
if err != nil {
return nil, fmt.Errorf("create report: %w", err)
}
return rep, nil
}
// CreatePostReport inserts a post-report row.
func (r *ReportRepo) CreatePostReport(ctx context.Context, reporterID, postID, category, reason string) (*model.PostReport, error) {
rep := &model.PostReport{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO post_reports (reporter_id, post_id, category, reason)
VALUES ($1, $2, $3, $4)
RETURNING id, reporter_id, post_id, category, reason, created_at, resolved_at`,
reporterID, postID, category, reason,
).Scan(&rep.ID, &rep.ReporterID, &rep.PostID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
if err != nil {
return nil, fmt.Errorf("create post report: %w", err)
}
return rep, nil
}
// ---------------------------------------------------------------------------
// NotificationRepo
// ---------------------------------------------------------------------------
// NotificationRepo persists notification rows. Reads (list / unread
// count / mark-read) will be added when the in-app notification center
// ships; for now the table is write-only, feeding the email fan-out and
// standing ready for websocket/push.
type NotificationRepo struct{ pg *Postgres }
func NewNotificationRepo(pg *Postgres) *NotificationRepo { return &NotificationRepo{pg: pg} }
// Create inserts a notification for recipient userID, triggered by
// actorID (nil for system notifications), of the given kind.
func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *string, kind string) (*model.Notification, error) {
n := &model.Notification{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO notifications (user_id, actor_id, kind)
VALUES ($1, $2, $3)
RETURNING id, user_id, actor_id, kind, created_at, read_at`,
userID, actorID, kind,
).Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt)
if err != nil {
return nil, fmt.Errorf("create notification: %w", err)
}
return n, nil
}
// ListByUser returns a recipient's notifications, newest first, with the
// actor's display fields joined in for rendering (nil for system
// notifications or since-deleted actors).
func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT n.id, n.user_id, n.actor_id, n.kind, n.created_at, n.read_at,
u.name, u.handle, u.avatar_url
FROM notifications n
LEFT JOIN users u ON u.id = n.actor_id
WHERE n.user_id = $1
ORDER BY n.created_at DESC
LIMIT $2 OFFSET $3`, userID, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("list notifications: %w", err)
}
defer rows.Close()
out := []model.Notification{}
for rows.Next() {
var n model.Notification
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt,
&n.ActorName, &n.ActorHandle, &n.ActorAvatar); err != nil {
return nil, fmt.Errorf("scan notification: %w", err)
}
out = append(out, n)
}
return out, rows.Err()
}
// CountUnread returns how many of a recipient's notifications are unread.
func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`, userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count unread notifications: %w", err)
}
return n, nil
}
// MarkAllRead stamps read_at on every still-unread notification for the
// user. Idempotent.
func (r *NotificationRepo) MarkAllRead(ctx context.Context, userID string) error {
_, err := r.pg.Pool.Exec(ctx,
`UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL`, userID,
)
if err != nil {
return fmt.Errorf("mark notifications read: %w", err)
}
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package repository
import (
"context"
"fmt"
"time"
)
// StatsRepo runs the aggregate count queries that power /stats. Lives
// in its own file rather than spreading COUNT methods across every
// domain repo — keeps the at-a-glance "system snapshot" queries in
// one place and one trip into the pool.
type StatsRepo struct{ pg *Postgres }
func NewStatsRepo(pg *Postgres) *StatsRepo { return &StatsRepo{pg: pg} }
// StatsSnapshot bundles totals, last-7-days counts, and feedback
// category breakdowns. One value rendered by /stats per request.
type StatsSnapshot struct {
Users int
UsersLast7Days int
Posts int
PostsLast7Days int
Collections int
PublicCollections int
Bookmarks int
Reshares int
Feedback int
FeedbackLast7Days int
FeedbackByCategory []CategoryCount
}
type CategoryCount struct {
Category string
Count int
}
// Snapshot runs every count needed by /stats. Queries run in series —
// the table sizes are modest enough that chasing parallelism here
// would buy sub-ms wins for added complexity.
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
s := &StatsSnapshot{}
sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
scalars := []struct {
sql string
args []any
dst *int
}{
{`SELECT COUNT(*) FROM users`, nil, &s.Users},
{`SELECT COUNT(*) FROM users WHERE created_at >= $1`, []any{sevenAgo}, &s.UsersLast7Days},
{`SELECT COUNT(*) FROM posts`, nil, &s.Posts},
{`SELECT COUNT(*) FROM posts WHERE created_at >= $1`, []any{sevenAgo}, &s.PostsLast7Days},
{`SELECT COUNT(*) FROM collections`, nil, &s.Collections},
{`SELECT COUNT(*) FROM collections WHERE is_public`, nil, &s.PublicCollections},
{`SELECT COUNT(*) FROM bookmarks`, nil, &s.Bookmarks},
{`SELECT COUNT(*) FROM posts WHERE reshared_from_post_id IS NOT NULL`, nil, &s.Reshares},
{`SELECT COUNT(*) FROM feedback`, nil, &s.Feedback},
{`SELECT COUNT(*) FROM feedback WHERE created_at >= $1`, []any{sevenAgo}, &s.FeedbackLast7Days},
}
for _, q := range scalars {
if err := r.pg.Pool.QueryRow(ctx, q.sql, q.args...).Scan(q.dst); err != nil {
return nil, fmt.Errorf("stats scalar %q: %w", q.sql, err)
}
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT category, COUNT(*)
FROM feedback
GROUP BY category
ORDER BY COUNT(*) DESC, category ASC`)
if err != nil {
return nil, fmt.Errorf("feedback by category: %w", err)
}
defer rows.Close()
for rows.Next() {
var cc CategoryCount
if err := rows.Scan(&cc.Category, &cc.Count); err != nil {
return nil, fmt.Errorf("scan category: %w", err)
}
s.FeedbackByCategory = append(s.FeedbackByCategory, cc)
}
return s, rows.Err()
}
+516
View File
@@ -0,0 +1,516 @@
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}
}
// userColumns is the canonical column list for SELECT/RETURNING on the
// users table, in the exact order scanUser reads them. Keeping the two in
// lockstep here is what makes adding a column a single-edit change instead
// of a hunt across every query.
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at, country,
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
preferred_chanting_time, peak_active_time`
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
// scanUser works for single-row and iterated reads alike.
type rowScanner interface {
Scan(dest ...any) error
}
// scanUser reads one users row (selected via userColumns) into u.
func scanUser(row rowScanner, u *model.User) error {
return row.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, &u.Country,
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
&u.PreferredChantingTime, &u.PeakActiveTime,
)
}
// ProfileUpdate is the set of fields UpdateProfile can change. Every field
// is a pointer so "leave unchanged" (nil) is distinct from "set". For the
// nullable string fields, a non-nil pointer to "" means "clear to NULL";
// DailyChantingRounds uses *0 as its clear sentinel (1 is the real min).
type ProfileUpdate struct {
Name *string
AvatarURL *string
Handle *string
Country *string
Sampradaya *string // nil=leave, ""=clear, code=set
Gender *string
Initiated *bool
InitiatedAt *string // "YYYY-MM-DD" to set, "" to clear
DailyChantingRounds *int // 0 to clear
PreferredChantingTime *string
PeakActiveTime *string
}
// 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) {
q := `
INSERT INTO users (email, password_hash, name, handle)
VALUES ($1, $2, $3, $4)
RETURNING ` + userColumns
u := &model.User{}
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle,
), u)
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)))
}
// CreateOAuth inserts a user with a NULL password_hash — used by the OAuth
// sign-in flow when there's no matching email yet. Email is nullable here:
// providers that don't return one (e.g. phone-only Facebook accounts) end
// up as users with email=NULL until they fill it in via profile settings.
// Same handle-collision retry contract as Create.
func (r *UserRepo) CreateOAuth(ctx context.Context, email *string, name, handle string, avatarURL *string, emailVerified bool) (*model.User, error) {
q := `
INSERT INTO users (email, password_hash, name, handle, avatar_url, email_verified_at)
VALUES ($1, NULL, $2, $3, $4, CASE WHEN $5::boolean THEN now() ELSE NULL END)
RETURNING ` + userColumns
var normEmail *string
if email != nil {
v := strings.ToLower(strings.TrimSpace(*email))
if v != "" {
normEmail = &v
}
}
u := &model.User{}
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
normEmail, name, handle, avatarURL, emailVerified,
), u)
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 oauth user: %w", err)
}
return u, nil
}
// 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 ` + userColumns + ` FROM users ` + where + ` LIMIT 1`
u := &model.User{}
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("find user: %w", err)
}
return u, nil
}
// SetEmail fills in (or replaces) a user's email and resets email_verified_at
// to NULL so a fresh verification flow can run. Returns ErrEmailTaken if
// another row already owns this address.
//
// Used by the "add your email later" flow for OAuth users who signed up
// without one. The caller (auth service) is expected to follow up by
// minting and sending a verification token.
func (r *UserRepo) SetEmail(ctx context.Context, id, email string) (*model.User, error) {
q := `
UPDATE users
SET email = $2,
email_verified_at = NULL,
updated_at = now()
WHERE id = $1
RETURNING ` + userColumns
u := &model.User{}
err := scanUser(r.pg.Pool.QueryRow(ctx, q, id, strings.ToLower(strings.TrimSpace(email))), u)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
if isUniqueViolation(err, "users_email_key") {
return nil, ErrEmailTaken
}
return nil, fmt.Errorf("set email: %w", err)
}
return u, nil
}
// UpdateProfile applies a partial profile patch. Only the non-nil fields of
// `p` are touched; the SQL is built dynamically so an untouched column is
// never written (keeps updated_at honest and the patch minimal). For the
// nullable string fields a non-nil "" clears the column to NULL.
//
// `handle` collisions surface as ErrHandleTaken so the handler can render
// a useful message.
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate) (*model.User, error) {
sets := make([]string, 0, 10)
args := []any{id}
// set appends "col = $N" with a bound value.
set := func(col string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args)))
}
// setExpr appends a parameterless SET clause (e.g. "col = NULL").
setExpr := func(expr string) { sets = append(sets, expr) }
// nullableString handles the nil=leave / ""=clear / value=set trio.
nullableString := func(col string, v *string) {
if v == nil {
return
}
if *v == "" {
setExpr(col + " = NULL")
} else {
set(col, *v)
}
}
if p.Name != nil {
set("name", *p.Name)
}
if p.Handle != nil {
set("handle", *p.Handle)
}
nullableString("avatar_url", p.AvatarURL)
nullableString("country", p.Country)
nullableString("sampradaya", p.Sampradaya)
nullableString("gender", p.Gender)
nullableString("preferred_chanting_time", p.PreferredChantingTime)
nullableString("peak_active_time", p.PeakActiveTime)
if p.Initiated != nil {
set("initiated", *p.Initiated)
}
if p.InitiatedAt != nil {
if *p.InitiatedAt == "" {
setExpr("initiated_at = NULL")
} else {
args = append(args, *p.InitiatedAt)
sets = append(sets, fmt.Sprintf("initiated_at = $%d::date", len(args)))
}
}
if p.DailyChantingRounds != nil {
if *p.DailyChantingRounds == 0 {
setExpr("daily_chanting_rounds = NULL")
} else {
set("daily_chanting_rounds", *p.DailyChantingRounds)
}
}
// Nothing to change — return the current row rather than a no-op UPDATE.
if len(sets) == 0 {
return r.findOne(ctx, `WHERE id = $1`, id)
}
sets = append(sets, "updated_at = now()")
q := `UPDATE users SET ` + strings.Join(sets, ", ") + ` WHERE id = $1 RETURNING ` + userColumns
u := &model.User{}
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
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
}
// SetCountryIfEmpty fills in the account country only when it's still
// NULL — used by the best-effort signup capture, so a later manual edit
// (or a second capture) never clobbers a value the user chose. `cc` is an
// ISO-3166-1 alpha-2 code; callers pass "" to no-op. Errors are the
// caller's to swallow (capture is fire-and-forget).
func (r *UserRepo) SetCountryIfEmpty(ctx context.Context, id, cc string) error {
cc = strings.ToUpper(strings.TrimSpace(cc))
if len(cc) != 2 {
return nil
}
_, err := r.pg.Pool.Exec(ctx, `
UPDATE users SET country = $2, updated_at = now()
WHERE id = $1 AND country IS NULL`, id, cc)
if err != nil {
return fmt.Errorf("set country: %w", err)
}
return 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
}
// Search returns users whose name or handle matches `q` (ILIKE
// substring). Leading "@" is stripped — callers can pass either
// "@foo" or "foo". Ordering bubbles handle-prefix matches above
// name-prefix above the rest, then alphabetic by name as the
// tiebreaker.
//
// Postgres ILIKE is fine at our user-table scale (low six figures
// max). When the table grows enough to warrant fuzzy / trigram
// matching, add a pg_trgm GIN index and switch to similarity().
func (r *UserRepo) Search(ctx context.Context, q string, limit int) ([]model.User, error) {
cleaned := strings.TrimPrefix(strings.TrimSpace(q), "@")
if cleaned == "" {
return []model.User{}, nil
}
if limit <= 0 || limit > 50 {
limit = 20
}
substring := "%" + cleaned + "%"
prefix := cleaned + "%"
sql := `SELECT ` + userColumns + `
FROM users
WHERE name ILIKE $1 OR handle::text ILIKE $1
ORDER BY
CASE
WHEN handle::text ILIKE $2 THEN 0
WHEN name ILIKE $2 THEN 1
ELSE 2
END,
name
LIMIT $3`
rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit)
if err != nil {
return nil, fmt.Errorf("search users: %w", err)
}
defer rows.Close()
out := []model.User{}
for rows.Next() {
var u model.User
if err := scanUser(rows, &u); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}
// GetReadingPrefs returns the raw JSONB bytes for the user's reading
// prefs (empty object when never set). The frontend owns the shape, so
// we round-trip the payload verbatim — no server-side schema enforced.
func (r *UserRepo) GetReadingPrefs(ctx context.Context, id string) ([]byte, error) {
const q = `SELECT reading_prefs FROM users WHERE id = $1 LIMIT 1`
var raw []byte
err := r.pg.Pool.QueryRow(ctx, q, id).Scan(&raw)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("get reading prefs: %w", err)
}
if len(raw) == 0 {
raw = []byte("{}")
}
return raw, nil
}
// UpdateReadingPrefs overwrites the user's reading prefs blob. Caller is
// responsible for validating that `prefsJSON` is well-formed JSON (the
// handler does this by re-marshalling the decoded struct before calling
// in).
func (r *UserRepo) UpdateReadingPrefs(ctx context.Context, id string, prefsJSON []byte) error {
const q = `UPDATE users SET reading_prefs = $2::jsonb, updated_at = now() WHERE id = $1`
tag, err := r.pg.Pool.Exec(ctx, q, id, prefsJSON)
if err != nil {
return fmt.Errorf("update reading prefs: %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
}
// FindValidForUser returns a non-used, non-expired token by user + hash +
// purpose. Used by the OTP login flow where the keyspace is small (6
// digits) — scoping by user_id prevents a global hash-collision lookup.
func (r *AuthTokenRepo) FindValidForUser(ctx context.Context, userID, 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 user_id = $1
AND token_hash = $2
AND purpose = $3
AND used_at IS NULL
AND expires_at > now()
LIMIT 1`
t := &model.AuthToken{}
err := r.pg.Pool.QueryRow(ctx, q, userID, 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
}
+346 -13
View File
@@ -12,7 +12,11 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/handler" "github.com/prabhupadasays/spsays-bff/internal/handler"
"github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/repository" "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/internal/worker"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
"github.com/prabhupadasays/spsays-bff/pkg/shorturl" "github.com/prabhupadasays/spsays-bff/pkg/shorturl"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -41,6 +45,21 @@ type Server struct {
transcriptionH *handler.TranscriptionHandler transcriptionH *handler.TranscriptionHandler
searchH *handler.SearchHandler searchH *handler.SearchHandler
shortURLH *handler.ShortURLHandler shortURLH *handler.ShortURLHandler
authH *handler.AuthHandler
oauthH *handler.OAuthHandler
userH *handler.UserHandler
postH *handler.PostHandler
bookmarkH *handler.BookmarkHandler
collectionH *handler.CollectionHandler
profileH *handler.ProfileHandler
relationH *handler.RelationshipHandler
notificationH *handler.NotificationHandler
feedbackH *handler.FeedbackHandler
statsH *handler.StatsHandler
// 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. // New creates a new Server with all routes registered.
@@ -86,10 +105,114 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
// Connection failures degrade gracefully — the BFF stays up; only /share returns 503. // Connection failures degrade gracefully — the BFF stays up; only /share returns 503.
s.initShortener(cfg, logger) 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() s.setupRouter()
return s 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)
oauthRepo := repository.NewOAuthAccountRepo(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, oauthRepo, 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)
// OAuth — wired only when at least one provider is configured. The
// registry skips providers with missing creds, so partial configs (e.g.
// Google only, no Facebook) work out of the box.
providers := oauth.NewRegistry(oauth.Credentials{
GoogleClientID: cfg.GoogleOAuthClientID,
GoogleClientSecret: cfg.GoogleOAuthClientSecret,
FacebookClientID: cfg.FacebookOAuthClientID,
FacebookClientSecret: cfg.FacebookOAuthClientSecret,
})
if len(providers) > 0 {
s.oauthH = handler.NewOAuthHandler(
authSvc, cookieOpts, providers,
[]byte(cfg.JWTSecret), cfg.BFFPublicURL, cfg.FrontendURL,
logger,
)
} else {
logger.Info("oauth: no providers configured — /auth/oauth/* will 404")
}
s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc)
s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo)
// Social graph: follows / blocks / reports, plus the notification
// "plugin" seam (persists rows + emails today; websocket/push later).
// All share the same Postgres pool + mailer. Reports reuse the
// feedback admin recipients.
followRepo := repository.NewFollowRepo(s.pg)
blockRepo := repository.NewBlockRepo(s.pg)
reportRepo := repository.NewReportRepo(s.pg)
notifRepo := repository.NewNotificationRepo(s.pg)
notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, logger, cfg.FrontendURL)
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
s.relationH = handler.NewRelationshipHandler(graphSvc)
s.notificationH = handler.NewNotificationHandler(notifSvc)
s.postH = handler.NewPostHandler(postSvc, graphSvc)
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc)
// Feedback + stats — share Postgres with the rest of /api/v1 and
// the existing mailer instance.
feedbackRepo := repository.NewFeedbackRepo(s.pg)
statsRepo := repository.NewStatsRepo(s.pg)
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo, feedbackSvc)
}
// initShortener wires Postgres + Redis + the shorturl handler/workers. // initShortener wires Postgres + Redis + the shorturl handler/workers.
// On connection failure it logs and leaves the components nil — the handler // On connection failure it logs and leaves the components nil — the handler
// short-circuits to 503 so the rest of the BFF keeps working. // short-circuits to 503 so the rest of the BFF keeps working.
@@ -152,6 +275,15 @@ func (s *Server) Close() {
s.logger.Info("all connections closed") 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 // injectBookCode is a middleware that sets "code" in chi's URL params
// for explicit sub-routes like /books/sb and /books/cc where {code} // for explicit sub-routes like /books/sb and /books/cc where {code}
// isn't a URL param but a fixed path prefix. // isn't a URL param but a fixed path prefix.
@@ -191,6 +323,19 @@ func (s *Server) setupRouter() {
w.Write([]byte(`{"status":"ok"}`)) w.Write([]byte(`{"status":"ok"}`))
}) })
// Public ops snapshot — aggregate counts + recent feedback rendered
// as HTML. Lives outside /api/v1 because it returns HTML, not JSON.
// Currently unauthenticated by product decision; gate here if that
// ever needs to change.
if s.statsH != nil {
r.Get("/stats", s.statsH.Render)
// One-way per-row feedback actions — POST so a refresh of /stats
// can't replay them. Both live at the same public access level as
// /stats itself (gate them together if that ever changes).
r.Post("/stats/feedback/{id}/acknowledge", s.statsH.AcknowledgeFeedback)
r.Post("/stats/feedback/{id}/resolve", s.statsH.ResolveFeedback)
}
// API v1 // API v1
r.Route("/api/v1", func(r chi.Router) { r.Route("/api/v1", func(r chi.Router) {
// Books // Books
@@ -367,20 +512,185 @@ func (s *Server) setupRouter() {
// Search (Typesense) // Search (Typesense)
r.Get("/search", s.searchH.Search) 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) { r.Route("/auth", func(r chi.Router) {
// TODO: register auth handlers (login, register, refresh) if s.authH == nil {
}) r.HandleFunc("/*", s.authUnavailable)
r.Route("/users", func(r chi.Router) { return
// TODO: register user handlers }
}) r.Post("/signup", s.authH.SignUp)
r.Route("/collections", func(r chi.Router) { r.Post("/login", s.authH.LogIn)
// TODO: register collection/bookmark handlers r.Post("/verify-login-otp", s.authH.VerifyLoginOTP)
}) r.Post("/logout", s.authH.LogOut)
r.Route("/mentions", func(r chi.Router) { r.Post("/forgot-password", s.authH.ForgotPassword)
// TODO: register mention handlers r.Post("/reset-password", s.authH.ResetPassword)
r.Post("/verify-email", s.authH.VerifyEmail)
// OAuth (Google, Facebook, …). GET endpoints because the browser
// navigates directly to /start (top-level redirect) and the
// provider redirects back to /callback. Both flows set cookies
// and 302 — they never return JSON.
if s.oauthH != nil {
r.Get("/oauth/{provider}/start", s.oauthH.Start)
r.Get("/oauth/{provider}/callback", s.oauthH.Callback)
}
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/me", s.authH.Me)
r.Get("/linked-providers", s.authH.LinkedProviders)
r.Post("/email", s.authH.SetEmail)
r.Post("/change-password", s.authH.ChangePassword)
r.Post("/resend-verification", s.authH.ResendVerification)
// Link-provider flow — requires the caller to be signed
// in; the state encodes their id so the callback links to
// THIS user instead of running the normal sign-in path.
// The callback itself is the shared /oauth/{provider}/callback
// above — the state's link_user_id field switches behaviour.
if s.oauthH != nil {
r.Get("/oauth/{provider}/link/start", s.oauthH.LinkStart)
}
})
}) })
// User profile — mixed public-read + protected writes.
r.Route("/users", func(r chi.Router) {
if s.userH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
// Public read — search by handle / name. Returns a
// sanitized payload (no email / role / plan / etc.).
r.Get("/search", s.userH.Search)
// Protected writes — profile edit, avatar presign,
// reading-prefs roaming.
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)
// Reading prefs roam with the account so theme/palette/
// font picks follow the user across devices. Body shape
// is owned by the frontend; the server treats it as an
// opaque JSON object.
r.Get("/me/reading-prefs", s.userH.GetReadingPrefs)
r.Put("/me/reading-prefs", s.userH.UpdateReadingPrefs)
})
})
// --- Social: posts ---
r.Route("/posts", func(r chi.Router) {
if s.postH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
// Public read — anonymous-OK but populates viewer_id when a
// session cookie is present, so the response can include
// per-viewer fields like `viewer_has_reshared`.
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/{id}", s.postH.Get)
})
// Protected — create / delete / image presign / "my posts" /
// reshare actions.
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)
// Reshare is auth-required: anonymous users can't claim
// to have reshared anything. The action is idempotent so
// double-clicks don't 4xx.
r.Post("/{id}/reshare", s.postH.Reshare)
r.Delete("/{id}/reshare", s.postH.Unreshare)
// Report a post (category + optional reason) → admin email.
r.Post("/{id}/report", s.postH.Report)
})
})
// --- 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.
// OptionalAuth on the GET lets us populate `viewer_has_reshared`
// on each post in the collection's carousel when a viewer is
// signed in, without rejecting anonymous traffic.
r.Route("/collections", func(r chi.Router) {
if s.collectionH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
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 ---
// OptionalAuth populates viewer_id when a session cookie is
// present, so the feed can carry `viewer_has_reshared` on each
// post. Without a cookie the routes still respond — they just
// return the anonymous shape (all-false).
r.Route("/u", func(r chi.Router) {
if s.profileH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
// Public reads (anonymous-OK; OptionalAuth populates viewer_id
// so Get can include the viewer's relationship + the feed can
// carry viewer_has_reshared).
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/{handle}", s.profileH.Get)
r.Get("/{handle}/feed", s.profileH.Feed)
})
// Relationship actions — follow/block/report. All require a
// signed-in viewer; each returns the recomputed relationship
// + the target's follower/following counts. Follows are
// request-based: POST /follow creates a PENDING request that
// the target later accepts or rejects.
if s.relationH != nil {
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Post("/{handle}/follow", s.relationH.Follow)
r.Delete("/{handle}/follow", s.relationH.Unfollow)
r.Post("/{handle}/follow/accept", s.relationH.AcceptFollow)
r.Post("/{handle}/follow/reject", s.relationH.RejectFollow)
r.Delete("/{handle}/follower", s.relationH.RemoveFollower)
r.Post("/{handle}/block", s.relationH.Block)
r.Delete("/{handle}/block", s.relationH.Unblock)
r.Post("/{handle}/report", s.relationH.Report)
})
}
})
// URL Shortener — Postgres-backed, Redis-queued. // URL Shortener — Postgres-backed, Redis-queued.
// The handler short-circuits to 503 if Postgres or Redis are unavailable. // The handler short-circuits to 503 if Postgres or Redis are unavailable.
r.Route("/share", func(r chi.Router) { r.Route("/share", func(r chi.Router) {
@@ -398,9 +708,32 @@ func (s *Server) setupRouter() {
} }
}) })
// Push Notifications // --- Feedback ---
// OptionalAuth so authed callers' user_id is captured
// automatically (handler reads middleware.UserID), and
// anonymous callers are still accepted (they supply
// contact_email instead).
r.Group(func(r chi.Router) {
if s.feedbackH == nil {
r.HandleFunc("/feedback", s.authUnavailable)
return
}
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Post("/feedback", s.feedbackH.Submit)
})
// Notifications — the signed-in user's in-app feed + unread badge.
// Rows are persisted on every follow/accept event; websocket + push
// delivery will layer on later. All protected.
r.Route("/notifications", func(r chi.Router) { r.Route("/notifications", func(r chi.Router) {
// TODO: register push notification handlers if s.notificationH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/", s.notificationH.List)
r.Get("/unread-count", s.notificationH.UnreadCount)
r.Post("/read", s.notificationH.MarkAllRead)
}) })
}) })
+817
View File
@@ -0,0 +1,817 @@
// 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/binary"
"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
oauth *repository.OAuthAccountRepo // optional — nil disables OAuth flows
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. `oauthRepo` may be nil — pass nil if
// the OAuth flows aren't wired (handler then returns 503).
func NewAuthService(
users *repository.UserRepo,
tokens *repository.AuthTokenRepo,
oauthRepo *repository.OAuthAccountRepo,
jwt *auth.Issuer,
mail mailer.Mailer,
logger *zap.Logger,
frontendURL string,
) *AuthService {
return &AuthService{
users: users,
tokens: tokens,
oauth: oauthRepo,
jwt: jwt,
mail: mail,
logger: logger,
frontend: strings.TrimRight(frontendURL, "/"),
PasswordResetTTL: 1 * time.Hour,
EmailVerifyTTL: 24 * time.Hour,
}
}
// CaptureCountry records the account's country only if it isn't already
// set. Best-effort: a blank code is a no-op, and DB errors are logged but
// swallowed so the caller (a post-signup goroutine) never fails on it.
func (s *AuthService) CaptureCountry(ctx context.Context, userID, country string) {
if strings.TrimSpace(country) == "" {
return
}
if err := s.users.SetCountryIfEmpty(ctx, userID, country); err != nil {
s.logger.Warn("capture signup country failed",
zap.String("user_id", userID), zap.Error(err))
}
}
// 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)
}
// LoginResult is the two-state outcome of LogIn:
//
// - `Auth != nil` — credentials verified AND email is fine. Caller
// sets the session cookie and returns the user.
// - `OTPRequired == true` — credentials verified but the account has been
// unverified for more than 30 days. An OTP has been emailed; the caller
// must collect it from the user and call VerifyLoginOTP to complete the
// sign-in. No session cookie is set yet.
//
// One of the two is always set on a non-error return.
type LoginResult struct {
Auth *AuthResult
OTPRequired bool
}
// staleUnverifiedAge is how long an account can stay unverified before
// every subsequent email/password login is gated on an OTP. Anything
// shorter and a normal user who simply hasn't checked their inbox yet
// would hit the gate; longer and abandoned-but-not-verified accounts
// remain a forever-credential. 30 days is the documented requirement.
const staleUnverifiedAge = 30 * 24 * time.Hour
// 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. May return early with OTPRequired=true; see LoginResult.
func (s *AuthService) LogIn(ctx context.Context, email, password string) (*LoginResult, error) {
u, err := s.users.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
return nil, ErrInvalidCredentials
}
return nil, err
}
// OAuth-only accounts have no password hash. Treat them as a credential
// mismatch so we don't reveal that an OAuth user exists for this email.
if u.PasswordHash == nil {
return nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
// OTP gate — unverified email AND account is older than the grace
// window. Mint an OTP and signal the caller to collect it. We do NOT
// issue a session here.
if u.EmailVerifiedAt == nil && time.Since(u.CreatedAt) > staleUnverifiedAge {
if u.Email == nil || *u.Email == "" {
// No email to send to → fall through to "invalid credentials"
// rather than expose this corner case.
return nil, ErrInvalidCredentials
}
if err := s.sendLoginOTP(ctx, u); err != nil {
s.logger.Warn("login otp send failed",
zap.String("user", u.ID), zap.Error(err))
return nil, fmt.Errorf("send login otp: %w", err)
}
return &LoginResult{OTPRequired: true}, nil
}
res, err := s.issueSession(u)
if err != nil {
return nil, err
}
return &LoginResult{Auth: res}, nil
}
// VerifyLoginOTP completes a login that was paused by the stale-unverified
// OTP gate. Re-checks the password (so an attacker who steals an OTP email
// still can't sign in without the password), validates the OTP, flips
// email_verified_at to now(), and issues a fresh session.
func (s *AuthService) VerifyLoginOTP(ctx context.Context, email, password, otp string) (*AuthResult, error) {
u, err := s.users.FindByEmail(ctx, email)
if err != nil {
return nil, ErrInvalidCredentials
}
if u.PasswordHash == nil {
return nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
otpHash := hashToken(otp)
tok, err := s.tokens.FindValidForUser(ctx, u.ID, otpHash, model.PurposeLoginOTP)
if err != nil {
return nil, ErrInvalidOrExpiredLink
}
if err := s.tokens.Consume(ctx, tok.ID); err != nil {
return nil, err
}
if err := s.users.MarkEmailVerified(ctx, u.ID); err != nil {
return nil, err
}
u2, err := s.users.FindByID(ctx, u.ID)
if err != nil {
return nil, err
}
return s.issueSession(u2)
}
// sendLoginOTP mints a fresh 6-digit code, invalidates any older pending
// codes, stores the SHA-256 hash, and emails the raw code to the user.
func (s *AuthService) sendLoginOTP(ctx context.Context, u *model.User) error {
if u.Email == nil || *u.Email == "" {
return errors.New("no email on file")
}
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeLoginOTP)
otp, hash, err := newOTP()
if err != nil {
return err
}
if _, err := s.tokens.Create(ctx, u.ID, model.PurposeLoginOTP, hash, 10*time.Minute); err != nil {
return err
}
body := loginOTPEmail(u.Name, otp)
return s.mail.Send(*u.Email, "Your login code — Prabhupāda Says", body)
}
// newOTP returns a freshly-generated 6-digit code + its SHA-256 hex hash.
// Modulo bias on a uniform 32-bit source is negligible for the 1M keyspace
// (10⁶ / 2³² ≈ 4 parts in 10⁴) and irrelevant against rate-limited online
// guessing, which is the only attack model we care about for this size.
func newOTP() (raw, hash string, err error) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return "", "", err
}
n := binary.BigEndian.Uint32(b[:]) % 1_000_000
raw = fmt.Sprintf("%06d", n)
hash = hashToken(raw)
return raw, hash, nil
}
// OAuthProfile is the provider-agnostic profile shape consumed by
// SignInWithOAuth. Mirrors pkg/oauth.Profile but lives here so the service
// layer doesn't import the OAuth package (which would create a layering
// cycle once handlers compose both).
type OAuthProfile struct {
Provider string
ProviderUserID string
Email string
EmailVerified bool
Name string
AvatarURL string
}
// ErrOAuthUnavailable means the OAuth repo wasn't wired (no Postgres at
// startup). Handler maps to 503.
var ErrOAuthUnavailable = errors.New("oauth service unavailable")
// ErrOAuthAccountClaimed is returned by LinkOAuthToCurrentUser when the
// provider identity is already linked to a different user. We refuse to
// move the linkage — the rightful owner of the OAuth account at the
// provider can sign in via the normal flow.
var ErrOAuthAccountClaimed = errors.New("oauth account already linked to another user")
// LinkOAuthToCurrentUser ties an OAuth provider identity to a specific
// already-authenticated user (settings → Connect flow). Refuses to move
// the linkage if some other user already claims it.
//
// Email policy: the user's primary email is NEVER touched by this call.
// The provider's email is stored on the oauth_accounts row only for
// reference (display in the future, audit). If the user wants to change
// their primary email they have to do it explicitly via SetEmail.
func (s *AuthService) LinkOAuthToCurrentUser(ctx context.Context, userID string, prof OAuthProfile) error {
if s.oauth == nil {
return ErrOAuthUnavailable
}
// If this (provider, sub) is already linked, the only valid case is
// that it's linked to the SAME user we're trying to link to. Any other
// user owning this linkage means the OAuth account was already claimed
// (typically the user mis-clicked or it's a different person on a
// shared device) — refuse rather than silently steal it.
if existing, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
if existing.UserID != userID {
return ErrOAuthAccountClaimed
}
// Same user — idempotent: refresh the denormalised profile fields
// and return success.
_, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
return err
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
return err
}
// Fresh link.
_, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
return err
}
// SignInWithOAuth turns a provider profile into a session. Three-step lookup:
//
// 1. Existing linkage on (provider, provider_user_id) → log that user in.
// 2. Provider gave us an email AND an existing user has the same email →
// link this provider to them, log in.
// 3. Otherwise create a new user (no password, email_verified iff we have
// an email) and link.
//
// When the provider returns no email (Facebook accounts with no verified
// email on file, accounts that declined the email scope, etc.) we skip
// the merge step and create the user with email=NULL. They can add an
// email later via PATCH /users/me — the frontend prompts them.
//
// When we DO have an email, every successful OAuth round-trip is treated
// as proof the user controls it — Google + Facebook both verify email
// ownership before returning it, so we mark the email as verified.
//
// In every case the resulting user gets a fresh JWT just like LogIn.
func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*AuthResult, error) {
if s.oauth == nil {
return nil, ErrOAuthUnavailable
}
// 1. Known linkage.
if a, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
u, err := s.users.FindByID(ctx, a.UserID)
if err != nil {
return nil, err
}
// Refresh denormalised fields on the linkage row (display name /
// avatar may have changed at the provider).
_, _ = s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
if prof.Email != "" {
s.ensureEmailVerified(ctx, u)
}
return s.issueSession(u)
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
return nil, err
}
// 2. Existing user with the same email → link.
// Only attempt this when the provider actually gave us an email. With
// no email there's no merge key — we just create a fresh user below.
if prof.Email != "" {
if u, err := s.users.FindByEmail(ctx, prof.Email); err == nil {
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
return nil, err
}
s.ensureEmailVerified(ctx, u)
return s.issueSession(u)
} else if !errors.Is(err, repository.ErrUserNotFound) {
return nil, err
}
}
// 3. Brand new user. Reuse the handle-collision retry loop.
displayName := prof.Name
if displayName == "" && prof.Email != "" {
displayName = strings.SplitN(prof.Email, "@", 2)[0]
}
if displayName == "" {
// No name AND no email — fall back to the provider name so the user
// at least has a non-empty display name they can rename later.
displayName = strings.Title(prof.Provider) + " user" //nolint:staticcheck // legacy Title is fine for ASCII labels
}
base := slugForHandle(prof.Email)
if base == "user" && prof.ProviderUserID != "" {
// slugForHandle returns "user" for empty input — prefer something
// less collision-prone for OAuth-no-email signups.
base = "user-" + safeSlugSuffix(prof.ProviderUserID)
}
emailVerified := prof.Email != "" // can only be verified if we have an email
var u *model.User
var err error
for attempt := 0; attempt < 8; attempt++ {
handle := base
if attempt > 0 {
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
}
u, err = s.users.CreateOAuth(ctx, optStr(prof.Email), displayName, handle, optStr(prof.AvatarURL), emailVerified)
if err == nil {
break
}
if errors.Is(err, repository.ErrEmailTaken) {
// Race: someone else just claimed this email. Fall back to step 2.
// Only reachable when prof.Email != "" (NULL emails never collide).
if u2, err2 := s.users.FindByEmail(ctx, prof.Email); err2 == nil {
_, _ = s.oauth.Link(ctx, u2.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
s.ensureEmailVerified(ctx, u2)
return s.issueSession(u2)
}
return nil, err
}
if !errors.Is(err, repository.ErrHandleTaken) {
return nil, err
}
}
if err != nil {
return nil, fmt.Errorf("create oauth user: %w", err)
}
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
return nil, err
}
return s.issueSession(u)
}
// safeSlugSuffix reduces an arbitrary provider id to a short ASCII tail
// suitable as part of a handle. Provider ids are typically all-digit
// (Facebook) or short ASCII (Google sub), so a trailing 8-char window is
// plenty and avoids handles getting silly-long.
func safeSlugSuffix(s string) string {
out := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return r
case r >= 'A' && r <= 'Z':
return r + ('a' - 'A')
default:
return -1
}
}, s)
if len(out) > 8 {
out = out[len(out)-8:]
}
if out == "" {
return randomHandleSuffix()
}
return out
}
// optStr returns nil for empty input, &s otherwise — used to populate
// nullable text columns from possibly-empty profile fields.
func optStr(s string) *string {
if s == "" {
return nil
}
return &s
}
// LinkedProviders returns the OAuth provider names already linked to the
// caller's account ("google", "facebook"). Used by the settings UI to flip
// the per-provider rows between "Connect" and "Connected".
func (s *AuthService) LinkedProviders(ctx context.Context, userID string) ([]string, error) {
if s.oauth == nil {
// OAuth repo not wired (no Postgres). No-op rather than erroring —
// settings just shows everything as un-connected.
return []string{}, nil
}
return s.oauth.ListProvidersForUser(ctx, userID)
}
// ChangePassword sets the caller's password, in two modes:
//
// 1. Account HAS a password — caller must supply the current one. We
// verify with bcrypt before replacing, defending against an attacker
// who got hold of the session cookie but doesn't know the password.
// 2. Account has NO password yet (OAuth-only signup) — caller can set
// one without proving anything beyond their existing session. There's
// no current password to verify against, and the session already
// grants them everything the new password would, so requiring an
// email round-trip here would only be friction.
//
// In both cases token_version is bumped (invalidating every JWT ever
// issued to this user) and a fresh session is re-issued so the calling
// device stays logged in while every other device is signed out.
func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) (*AuthResult, error) {
if len(newPassword) < 8 {
return nil, ErrWeakPassword
}
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, ErrUserNotFound
}
if u.PasswordHash != nil {
// Account has a password — current must match before we'll replace it.
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(currentPassword)); err != nil {
return nil, ErrInvalidCredentials
}
}
// If u.PasswordHash == nil we skip the verification: setting an initial
// password for an OAuth-only account doesn't require proving anything
// beyond the active session.
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
if err := s.users.UpdatePassword(ctx, userID, string(hash)); err != nil {
return nil, err
}
// Re-fetch to pick up the new token_version so the freshly-issued JWT
// has the right "tv" claim and the cookie set after this call keeps
// the user logged in on this device.
u2, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, err
}
return s.issueSession(u2)
}
// SetEmail fills in (or replaces) the caller's email address. Used by the
// "add your email" flow for OAuth users who signed up without one, and by
// any user who later wants to change their address.
//
// The address is stored as unverified — a verification link is sent to it
// immediately. Returns ErrEmailAlreadyTaken if another row already owns it.
func (s *AuthService) SetEmail(ctx context.Context, userID, email string) (*model.User, error) {
u, err := s.users.SetEmail(ctx, userID, email)
if err != nil {
if errors.Is(err, repository.ErrEmailTaken) {
return nil, ErrEmailAlreadyTaken
}
return nil, err
}
if err := s.sendVerificationEmail(ctx, u); err != nil {
// Verification email failure shouldn't block the set — the user can
// always trigger a resend.
s.logger.Warn("verification email failed after SetEmail",
zap.String("user", u.ID), zap.Error(err))
}
return u, nil
}
// ensureEmailVerified flips email_verified_at to now() on the given user
// if it's not already set. Called from every SignInWithOAuth branch so a
// successful OAuth round-trip immediately satisfies email verification
// for an existing-but-unverified account. Mutates u in place so callers
// see the updated timestamp without re-fetching.
func (s *AuthService) ensureEmailVerified(ctx context.Context, u *model.User) {
if u == nil || u.EmailVerifiedAt != nil {
return
}
if err := s.users.MarkEmailVerified(ctx, u.ID); err != nil {
s.logger.Warn("mark email verified after oauth", zap.String("user", u.ID), zap.Error(err))
return
}
now := time.Now()
u.EmailVerifiedAt = &now
}
// 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
}
// OAuth-only users may have no email on file — skip the send silently
// (the caller already cannot tell whether the email was registered, so
// "no email to send to" surfaces the same way).
if u.Email == nil || *u.Email == "" {
return nil
}
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 {
// Users with no email on file (OAuth signups where the provider didn't
// return one) have nothing to send to. The caller is expected to gate
// on this, but defensively no-op here too.
if u.Email == nil || *u.Email == "" {
return nil
}
_ = 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 loginOTPEmail(name, otp string) string {
return fmt.Sprintf(`<p>Hari Bol %s,</p>
<p>Your account on <strong>Prabhupāda Says</strong> has been unverified for a
while, so we need a quick one-time code before signing you in:</p>
<p style="font-size:1.5rem;font-weight:600;letter-spacing:0.25em;margin:1.25rem 0;">%s</p>
<p>This code expires in 10 minutes. If you didn't try to sign in, you can
safely ignore this email.</p>`, htmlEscape(name), otp)
}
func htmlEscape(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
)
return r.Replace(s)
}
+356
View File
@@ -0,0 +1,356 @@
// Package service — feedback flow. Sits between the feedback handler
// and FeedbackRepo: validates the submission, writes the row, then
// fans an HTML notification email out to every address listed in
// FEEDBACK_ADMIN_EMAILS. The email is fire-and-forget — transient
// send failures are logged and don't fail the request, because the
// row is already in the DB and visible on /stats.
package service
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
)
type FeedbackService struct {
repo *repository.FeedbackRepo
mail mailer.Mailer
admins []string
logger *zap.Logger
}
func NewFeedbackService(repo *repository.FeedbackRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
return &FeedbackService{repo: repo, mail: mail, admins: admins, logger: logger}
}
// Allowed categories — kept lowercase in storage so dashboards group
// cleanly. The frontend dropdown sends one of these exact strings.
var feedbackCategories = map[string]bool{
"bug": true,
"feature": true,
"general": true,
"other": true,
}
// Submit persists a feedback row and fires the admin email.
//
// userID is nil for anonymous submissions; contactEmail is the
// optional reply-to in that case. For authed users we ignore whatever
// the form sent and read the canonical email off the joined user row
// when assembling the notification — that keeps the reply path
// trustworthy (the form can't lie about who's writing).
func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
category = strings.ToLower(strings.TrimSpace(category))
if !feedbackCategories[category] {
return nil, errors.New("invalid category")
}
message = strings.TrimSpace(message)
if message == "" {
return nil, errors.New("message is required")
}
if len(message) > 5000 {
return nil, errors.New("message too long (max 5000)")
}
if userID == nil {
if contactEmail == nil || strings.TrimSpace(*contactEmail) == "" {
return nil, errors.New("contact_email is required when not logged in")
}
trimmed := strings.TrimSpace(*contactEmail)
contactEmail = &trimmed
} else {
// Authed: ignore client-supplied contact_email; user row owns it.
contactEmail = nil
}
f, err := s.repo.Create(ctx, userID, category, message, contactEmail)
if err != nil {
return nil, err
}
// Hydrate the joined author fields for the email template. Failing
// the lookup isn't worth failing the request — we fall back to
// "anonymous" rather than 500ing on the user.
if userID != nil {
if full, err := s.repo.FindByID(ctx, f.ID); err == nil {
f = full
} else {
s.logger.Warn("feedback hydrate failed; sending email without author info",
zap.String("feedback_id", f.ID), zap.Error(err))
}
}
s.notifyAdmins(f)
return f, nil
}
// ErrAlreadyResolved is returned by Resolve when the row was already
// closed (or doesn't exist). The handler maps it to a benign redirect
// rather than an error — the end state the admin wanted is already true.
var ErrAlreadyResolved = errors.New("feedback already resolved")
// ErrAlreadyAcknowledged is the acknowledge-path counterpart. Same
// benign-redirect treatment in the handler.
var ErrAlreadyAcknowledged = errors.New("feedback already acknowledged")
// Acknowledge marks a feedback row acknowledged (one-way) and, if a
// recipient address is known, emails the author that the team has seen
// their feedback and — if it needs work — the devs have started, with a
// second email promised on completion.
//
// Mirrors Resolve: MarkAcknowledged's `acknowledged_at IS NULL` guard
// guarantees the email fires at most once; the email is fire-and-forget.
func (s *FeedbackService) Acknowledge(ctx context.Context, id string) error {
acked, err := s.repo.MarkAcknowledged(ctx, id)
if err != nil {
return err
}
if !acked {
return ErrAlreadyAcknowledged
}
f, err := s.repo.FindByID(ctx, id)
if err != nil {
s.logger.Warn("feedback acknowledged but hydrate failed; skipping email",
zap.String("feedback_id", id), zap.Error(err))
return nil
}
to := feedbackReplyTo(f)
if to == "" {
s.logger.Info("feedback acknowledged; no recipient address, skipping email",
zap.String("feedback_id", id))
return nil
}
body, err := renderFeedbackAcknowledgedEmail(f)
if err != nil {
s.logger.Warn("render feedback-acknowledged email failed", zap.Error(err))
return nil
}
if err := s.mail.Send(to, "We've received your feedback — Prabhupāda Says", body); err != nil {
s.logger.Warn("feedback-acknowledged email send failed",
zap.String("to", to), zap.Error(err))
}
return nil
}
// Resolve marks a feedback row resolved (one-way) and, if a recipient
// address is known, sends the author a Vaishnava-styled thank-you note.
//
// The DB update is the source of truth: MarkResolved's `resolved_at IS
// NULL` guard guarantees the email fires at most once even under
// concurrent button presses. The email is fire-and-forget — a send
// failure logs but doesn't un-resolve the row.
func (s *FeedbackService) Resolve(ctx context.Context, id string) error {
resolved, err := s.repo.MarkResolved(ctx, id)
if err != nil {
return err
}
if !resolved {
return ErrAlreadyResolved
}
// Re-read with author joins so we know where to send the thank-you.
f, err := s.repo.FindByID(ctx, id)
if err != nil {
// Row IS resolved; we just can't assemble the email. Log and
// succeed — the resolve itself stuck.
s.logger.Warn("feedback resolved but hydrate failed; skipping thank-you email",
zap.String("feedback_id", id), zap.Error(err))
return nil
}
to := feedbackReplyTo(f)
if to == "" {
// No email on file (anonymous submitter who left no contact, or
// a since-deleted account). Nothing to send — that's fine.
s.logger.Info("feedback resolved; no recipient address, skipping thank-you email",
zap.String("feedback_id", id))
return nil
}
body, err := renderFeedbackResolvedEmail(f)
if err != nil {
s.logger.Warn("render feedback-resolved email failed", zap.Error(err))
return nil
}
if err := s.mail.Send(to, "Your feedback has been addressed — Prabhupāda Says", body); err != nil {
s.logger.Warn("feedback-resolved email send failed",
zap.String("to", to), zap.Error(err))
}
return nil
}
// feedbackReplyTo picks the address to thank: the authed submitter's
// account email if present, else the anonymous contact_email, else "".
func feedbackReplyTo(f *model.Feedback) string {
if f.AuthorEmail != nil && strings.TrimSpace(*f.AuthorEmail) != "" {
return strings.TrimSpace(*f.AuthorEmail)
}
if f.ContactEmail != nil && strings.TrimSpace(*f.ContactEmail) != "" {
return strings.TrimSpace(*f.ContactEmail)
}
return ""
}
// notifyAdmins fires one HTML email per configured admin. Each
// failure logs and falls through — the row is persisted so a missed
// email is annoying but not data-losing.
func (s *FeedbackService) notifyAdmins(f *model.Feedback) {
if len(s.admins) == 0 {
s.logger.Info("feedback received but no admin recipients configured",
zap.String("feedback_id", f.ID))
return
}
body, err := renderFeedbackEmail(f)
if err != nil {
s.logger.Warn("render feedback email failed", zap.Error(err))
return
}
subject := fmt.Sprintf("[Feedback · %s] %s", f.Category, summarize(f.Message, 60))
for _, to := range s.admins {
if err := s.mail.Send(to, subject, body); err != nil {
s.logger.Warn("feedback email send failed",
zap.String("to", to), zap.Error(err))
}
}
}
func summarize(s string, max int) string {
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-1]) + "…"
}
var feedbackEmailTpl = template.Must(template.New("feedback").Parse(`<p><strong>New feedback received</strong></p>
<p><strong>Category:</strong> {{.Category}}<br>
<strong>Submitted:</strong> {{.CreatedAt}}<br>
<strong>From:</strong> {{.From}}</p>
<p><strong>Message:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Message}}</pre>
<p style="color:#999;font-size:12px">Feedback ID: {{.ID}}</p>`))
func renderFeedbackEmail(f *model.Feedback) (string, error) {
var from string
switch {
case f.AuthorHandle != nil && *f.AuthorHandle != "":
from = fmt.Sprintf("%s (@%s)", derefOr(f.AuthorName, "—"), *f.AuthorHandle)
if f.AuthorEmail != nil && *f.AuthorEmail != "" {
from += " — " + *f.AuthorEmail
}
case f.ContactEmail != nil && *f.ContactEmail != "":
from = "anonymous — " + *f.ContactEmail
default:
from = "anonymous"
}
var buf bytes.Buffer
err := feedbackEmailTpl.Execute(&buf, map[string]any{
"Category": f.Category,
"Message": f.Message,
"From": from,
"CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"),
"ID": f.ID,
})
if err != nil {
return "", err
}
return buf.String(), nil
}
func derefOr(p *string, fallback string) string {
if p == nil || *p == "" {
return fallback
}
return *p
}
// feedbackResolvedEmailTpl is the author-facing thank-you sent when an
// admin marks their feedback resolved. Vaishnava-styled, short, and
// quotes back the exact feedback the devotee submitted so they know
// precisely what was addressed. Base font is 17px for comfortable
// reading on phones.
var feedbackResolvedEmailTpl = template.Must(template.New("feedbackResolved").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏</p>
<p>The feedback you shared with us has been addressed and the necessary
changes are now live:</p>
<blockquote style="margin:1em 0;padding:0.75em 1em;border-left:3px solid #d6b27a;background:#faf5ea;color:#5a4a32;font-style:italic;white-space:pre-wrap">{{.Message}}</blockquote>
<p>Thank you for helping us serve Śrīla Prabhupāda's <em>vāṇī</em> more
faithfully. Please continue using
<a href="https://prabhupadasays.org" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>
with even greater enthusiasm.</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
// feedbackAcknowledgedEmailTpl is the author-facing "we've seen it"
// note sent when an admin presses "Acknowledge". Same Vaishnava styling
// and 17px base as the resolved email, quotes the submission back, and
// promises a follow-up email on completion.
var feedbackAcknowledgedEmailTpl = template.Must(template.New("feedbackAcknowledged").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏</p>
<p>Thank you for the feedback you shared with us:</p>
<blockquote style="margin:1em 0;padding:0.75em 1em;border-left:3px solid #d6b27a;background:#faf5ea;color:#5a4a32;font-style:italic;white-space:pre-wrap">{{.Message}}</blockquote>
<p>We acknowledge your feedback and have noted it carefully. If it calls
for a new feature or a fix, our dev team has already begun working on it.
You'll receive another email from us once it has been implemented or
resolved.</p>
<p>Thank you for helping us serve Śrīla Prabhupāda's <em>vāṇī</em> more
faithfully. Please continue using
<a href="https://prabhupadasays.org" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>
with even greater enthusiasm.</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
func renderFeedbackAcknowledgedEmail(f *model.Feedback) (string, error) {
name := ""
if f.AuthorName != nil {
name = strings.TrimSpace(*f.AuthorName)
}
var buf bytes.Buffer
if err := feedbackAcknowledgedEmailTpl.Execute(&buf, map[string]any{
"Name": name,
"Message": strings.TrimSpace(f.Message),
}); err != nil {
return "", err
}
return buf.String(), nil
}
func renderFeedbackResolvedEmail(f *model.Feedback) (string, error) {
// Prefer the account display name; fall back to no name (the
// template drops the comma cleanly when Name is empty).
name := ""
if f.AuthorName != nil {
name = strings.TrimSpace(*f.AuthorName)
}
var buf bytes.Buffer
if err := feedbackResolvedEmailTpl.Execute(&buf, map[string]any{
"Name": name,
"Message": strings.TrimSpace(f.Message),
}); err != nil {
return "", err
}
return buf.String(), nil
}
+189
View File
@@ -0,0 +1,189 @@
// Package service — NotificationService is the single seam every
// user-facing event flows through ("the notification plugin"). Today it
// does two things per event:
//
// 1. Persists a row in `notifications` (so an in-app notification center
// and, later, websocket + push delivery can read the same history).
// 2. Sends an email to the recipient via the shared mailer.
//
// Everything here is fire-and-forget from the caller's perspective: a
// follow request must succeed even if the email bounces or the insert
// fails, so Notify* never returns an error — failures are logged. When
// websocket/push land they hook in right here alongside the email send.
package service
import (
"bytes"
"context"
"html/template"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
)
// Notification kinds — short machine strings stored in notifications.kind.
const (
NotifFollowRequest = "follow_request"
NotifFollowAccepted = "follow_accepted"
)
type NotificationService struct {
notifs *repository.NotificationRepo
users *repository.UserRepo
mail mailer.Mailer
logger *zap.Logger
frontendURL string
}
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *NotificationService {
return &NotificationService{
notifs: notifs,
users: users,
mail: mail,
logger: logger,
frontendURL: strings.TrimRight(frontendURL, "/"),
}
}
// List returns the caller's notifications, newest first.
func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
if limit <= 0 || limit > 100 {
limit = 30
}
if offset < 0 {
offset = 0
}
return s.notifs.ListByUser(ctx, userID, limit, offset)
}
// UnreadCount returns the caller's unread notification count (for the bell
// badge).
func (s *NotificationService) UnreadCount(ctx context.Context, userID string) (int, error) {
return s.notifs.CountUnread(ctx, userID)
}
// MarkAllRead marks all of the caller's notifications read.
func (s *NotificationService) MarkAllRead(ctx context.Context, userID string) error {
return s.notifs.MarkAllRead(ctx, userID)
}
// NotifyFollowRequest tells `recipientID` that `actorID` asked to follow
// them. Persists the row and emails the recipient (if they have an email
// on file).
func (s *NotificationService) NotifyFollowRequest(ctx context.Context, recipientID, actorID string) {
s.dispatch(ctx, recipientID, actorID, NotifFollowRequest,
"%s requested to follow you — Prabhupāda Says")
}
// NotifyFollowAccepted tells `recipientID` that `actorID` accepted their
// follow request (i.e. the recipient can now see actor's activity).
func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, recipientID, actorID string) {
s.dispatch(ctx, recipientID, actorID, NotifFollowAccepted,
"%s accepted your follow request — Prabhupāda Says")
}
// dispatch persists the notification and sends the matching email. All
// failures are logged, never propagated.
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) {
actor := actorID
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
s.logger.Warn("persist notification failed",
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
// Persistence failed, but still try to email — the email is the
// only delivery channel that exists today.
}
recipient, err := s.users.FindByID(ctx, recipientID)
if err != nil {
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err))
return
}
if recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
// No email on file (OAuth-only account that never added one) —
// the persisted row is the notification; nothing to send.
return
}
actorUser, err := s.users.FindByID(ctx, actorID)
if err != nil {
s.logger.Warn("notification actor lookup failed", zap.String("actor", actorID), zap.Error(err))
return
}
body, err := s.render(kind, recipient, actorUser)
if err != nil {
s.logger.Warn("render notification email failed", zap.String("kind", kind), zap.Error(err))
return
}
subject := strings.Replace(subjectFmt, "%s", actorDisplay(actorUser), 1)
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, body); err != nil {
s.logger.Warn("notification email send failed",
zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
}
}
func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) {
data := map[string]any{
"RecipientName": recipient.Name,
"ActorName": actorDisplay(actor),
"ActorHandle": actor.Handle,
"ProfileURL": s.frontendURL + "/u/" + actor.Handle,
}
var tpl *template.Template
switch kind {
case NotifFollowRequest:
tpl = followRequestEmailTpl
case NotifFollowAccepted:
tpl = followAcceptedEmailTpl
default:
tpl = followRequestEmailTpl
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// actorDisplay prefers the display name, falling back to @handle.
func actorDisplay(u *model.User) string {
if n := strings.TrimSpace(u.Name); n != "" {
return n
}
return "@" + u.Handle
}
// Vaishnava-styled, 17px base to match the feedback emails. Both quote
// the actor and link to their profile (where the recipient can accept a
// pending request).
var followRequestEmailTpl = template.Must(template.New("followRequest").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
has requested to follow you on Prabhupāda Says.</p>
<p>You can review and accept or decline the request from their profile:</p>
<p style="margin:1.25em 0">
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View request</a>
</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
var followAcceptedEmailTpl = template.Must(template.New("followAccepted").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
has accepted your follow request. You're now following them on Prabhupāda Says.</p>
<p style="margin:1.25em 0">
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Visit profile</a>
</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
+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
}
+435
View File
@@ -0,0 +1,435 @@
// Package service — SocialGraphService is the follow / block / report
// brain. It sits over FollowRepo + BlockRepo + ReportRepo, resolves the
// target by handle, enforces the rules (no self-follow, no following
// across a block), keeps the request-based follow lifecycle honest, and
// routes every notable event through the NotificationService.
//
// Follows are request-based end to end: RequestFollow only ever creates a
// PENDING edge; the target turns it into a real follow via AcceptFollow.
// Every mutating method returns the freshly-recomputed RelationshipResult
// (the viewer's state toward the target + the target's follower/following
// counts) so the frontend can update its buttons and stat line from one
// response.
package service
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
)
// Sentinels specific to the social graph (ErrForbidden / ErrNotFound are
// shared from social_service.go in this package).
var (
// ErrBlocked — the action can't proceed because one of the two users
// has blocked the other.
ErrBlocked = errors.New("blocked")
// ErrSelfRelation — you can't follow / block / report yourself.
ErrSelfRelation = errors.New("cannot perform this action on yourself")
)
// reportCategories is the allow-list the Report flow validates against —
// mirrored by the frontend's report dialog dropdown.
var reportCategories = map[string]bool{
"spam": true,
"harassment": true,
"impersonation": true,
"inappropriate": true,
"other": true,
}
// postReportCategories is the allow-list for reporting a post — mirrored
// by the frontend's post report dialog.
var postReportCategories = map[string]bool{
"spam": true,
"harassment": true,
"misinformation": true,
"inappropriate": true,
"other": true,
}
// RelationshipResult is the payload every relationship mutation returns:
// the viewer's relationship to the target plus the target's live counts.
type RelationshipResult struct {
Relationship *model.RelationshipState `json:"relationship"`
FollowerCount int `json:"follower_count"`
FollowingCount int `json:"following_count"`
}
type SocialGraphService struct {
follows *repository.FollowRepo
blocks *repository.BlockRepo
reports *repository.ReportRepo
posts *repository.PostRepo
users *repository.UserRepo
notif *NotificationService
mail mailer.Mailer
admins []string
logger *zap.Logger
}
func NewSocialGraphService(
follows *repository.FollowRepo,
blocks *repository.BlockRepo,
reports *repository.ReportRepo,
posts *repository.PostRepo,
users *repository.UserRepo,
notif *NotificationService,
mail mailer.Mailer,
admins []string,
logger *zap.Logger,
) *SocialGraphService {
return &SocialGraphService{
follows: follows, blocks: blocks, reports: reports, posts: posts,
users: users, notif: notif, mail: mail, admins: admins, logger: logger,
}
}
// resolve looks up the target by handle and rejects self-targeting.
func (s *SocialGraphService) resolve(ctx context.Context, viewerID, handle string) (*model.User, error) {
t, err := s.users.FindByHandle(ctx, handle)
if err != nil {
return nil, err // ErrUserNotFound → 404 via mapSocialErr
}
if t.ID == viewerID {
return nil, ErrSelfRelation
}
return t, nil
}
// RequestFollow inserts a pending follow edge viewer→target and notifies
// the target. Refuses if either party has blocked the other. Idempotent:
// re-requesting an existing edge doesn't re-notify.
func (s *SocialGraphService) RequestFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
viewerBlocked, targetBlocked, err := s.blocks.BlockState(ctx, viewerID, t.ID)
if err != nil {
return nil, err
}
if viewerBlocked || targetBlocked {
return nil, ErrBlocked
}
created, err := s.follows.Request(ctx, viewerID, t.ID)
if err != nil {
return nil, err
}
if created {
s.notif.NotifyFollowRequest(ctx, t.ID, viewerID)
}
return s.result(ctx, viewerID, t)
}
// Unfollow removes the viewer→target edge — covers both "stop following"
// (accepted) and "cancel my pending request". Idempotent.
func (s *SocialGraphService) Unfollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.follows.Delete(ctx, viewerID, t.ID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// AcceptFollow accepts the target's pending request to follow the viewer
// (edge target→viewer). On the pending→accepted transition the target is
// notified that their request went through. `handle` is the requester.
func (s *SocialGraphService) AcceptFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
accepted, err := s.follows.Accept(ctx, t.ID, viewerID)
if err != nil {
return nil, err
}
if accepted {
s.notif.NotifyFollowAccepted(ctx, t.ID, viewerID)
}
return s.result(ctx, viewerID, t)
}
// RejectFollow declines the target's pending request to follow the viewer
// by deleting the target→viewer edge. Idempotent; no notification (a
// silent decline is kinder than an explicit rejection email).
func (s *SocialGraphService) RejectFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.follows.Delete(ctx, t.ID, viewerID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// RemoveFollower drops the target from the viewer's followers by deleting
// the target→viewer edge. Mechanically the same as RejectFollow but
// distinct in intent (an accepted follower is being removed). Idempotent.
func (s *SocialGraphService) RemoveFollower(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.follows.Delete(ctx, t.ID, viewerID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// Block records viewer→target and severs any follow edges both ways.
func (s *SocialGraphService) Block(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.blocks.Block(ctx, viewerID, t.ID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// Unblock removes the viewer→target block.
func (s *SocialGraphService) Unblock(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.blocks.Unblock(ctx, viewerID, t.ID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// Report files a report against the target and emails the admins. The
// category must be in the allow-list and the reason non-empty.
func (s *SocialGraphService) Report(ctx context.Context, viewerID, handle, category, reason string) error {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return err
}
category = strings.ToLower(strings.TrimSpace(category))
if !reportCategories[category] {
return errors.New("invalid report category")
}
reason = strings.TrimSpace(reason)
if reason == "" {
return errors.New("reason is required")
}
if len(reason) > 2000 {
return errors.New("reason too long (max 2000)")
}
if _, err := s.reports.Create(ctx, viewerID, t.ID, category, reason); err != nil {
return err
}
// Email the admins — fire-and-forget; the row is already persisted.
reporter, rerr := s.users.FindByID(ctx, viewerID)
if rerr != nil {
s.logger.Warn("report saved but reporter lookup failed; emailing without reporter info",
zap.String("reporter", viewerID), zap.Error(rerr))
}
s.notifyAdminsOfReport(reporter, t, category, reason)
return nil
}
// ReportPost files a report against a specific post and emails the admins.
// The category must be in the post allow-list; the reason is optional.
func (s *SocialGraphService) ReportPost(ctx context.Context, reporterID, postID, category, reason string) error {
category = strings.ToLower(strings.TrimSpace(category))
if !postReportCategories[category] {
return errors.New("invalid report category")
}
reason = strings.TrimSpace(reason)
if len(reason) > 2000 {
return errors.New("reason too long (max 2000)")
}
post, err := s.posts.FindByID(ctx, postID)
if err != nil {
return err // ErrPostNotFound → 404 via mapSocialErr
}
if _, err := s.reports.CreatePostReport(ctx, reporterID, post.ID, category, reason); err != nil {
return err
}
reporter, rerr := s.users.FindByID(ctx, reporterID)
if rerr != nil {
s.logger.Warn("post report saved but reporter lookup failed; emailing without reporter info",
zap.String("reporter", reporterID), zap.Error(rerr))
}
s.notifyAdminsOfPostReport(reporter, post, category, reason)
return nil
}
// Relationship computes the viewer's relationship toward targetID.
func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetID string) (*model.RelationshipState, error) {
out := &model.RelationshipState{}
outStatus, err := s.follows.Status(ctx, viewerID, targetID) // viewer → target
if err != nil {
return nil, err
}
inStatus, err := s.follows.Status(ctx, targetID, viewerID) // target → viewer
if err != nil {
return nil, err
}
out.Following = outStatus == "accepted"
out.FollowRequested = outStatus == "pending"
out.FollowsYou = inStatus == "accepted"
out.RequestsYou = inStatus == "pending"
viewerBlocked, blockedByTarget, err := s.blocks.BlockState(ctx, viewerID, targetID)
if err != nil {
return nil, err
}
out.Blocked = viewerBlocked
out.BlockedBy = blockedByTarget
return out, nil
}
// Counts returns the accepted-follower and accepted-following totals for
// a user.
func (s *SocialGraphService) Counts(ctx context.Context, userID string) (followers, following int, err error) {
followers, err = s.follows.CountFollowers(ctx, userID)
if err != nil {
return 0, 0, err
}
following, err = s.follows.CountFollowing(ctx, userID)
if err != nil {
return 0, 0, err
}
return followers, following, nil
}
// ProfileExtras is the read used by the public-profile endpoint: the
// target's follower/following counts plus, when a non-owner viewer is
// signed in, their relationship to the target. rel is nil for anonymous
// viewers and for the owner viewing their own profile.
func (s *SocialGraphService) ProfileExtras(ctx context.Context, viewerID string, target *model.User) (followers, following int, rel *model.RelationshipState, err error) {
followers, following, err = s.Counts(ctx, target.ID)
if err != nil {
return 0, 0, nil, err
}
if viewerID == "" || viewerID == target.ID {
return followers, following, nil, nil
}
rel, err = s.Relationship(ctx, viewerID, target.ID)
if err != nil {
return 0, 0, nil, err
}
return followers, following, rel, nil
}
// result recomputes the viewer→target relationship and the target's
// counts after a mutation.
func (s *SocialGraphService) result(ctx context.Context, viewerID string, target *model.User) (*RelationshipResult, error) {
rel, err := s.Relationship(ctx, viewerID, target.ID)
if err != nil {
return nil, err
}
followers, following, err := s.Counts(ctx, target.ID)
if err != nil {
return nil, err
}
return &RelationshipResult{
Relationship: rel,
FollowerCount: followers,
FollowingCount: following,
}, nil
}
func (s *SocialGraphService) notifyAdminsOfReport(reporter, reported *model.User, category, reason string) {
if len(s.admins) == 0 {
s.logger.Info("user report received but no admin recipients configured",
zap.String("reported", reported.ID))
return
}
from := "anonymous"
if reporter != nil {
from = fmt.Sprintf("%s (@%s)", reporter.Name, reporter.Handle)
}
var buf bytes.Buffer
if err := userReportEmailTpl.Execute(&buf, map[string]any{
"ReporterFrom": from,
"ReportedName": reported.Name,
"ReportedHandle": reported.Handle,
"Category": category,
"Reason": reason,
}); err != nil {
s.logger.Warn("render user-report email failed", zap.Error(err))
return
}
subject := fmt.Sprintf("[Report · %s] @%s", category, reported.Handle)
body := buf.String()
for _, to := range s.admins {
if err := s.mail.Send(to, subject, body); err != nil {
s.logger.Warn("user-report email send failed", zap.String("to", to), zap.Error(err))
}
}
}
var userReportEmailTpl = template.Must(template.New("userReport").Parse(`<p><strong>A user has been reported</strong></p>
<p><strong>Reported:</strong> {{.ReportedName}} (@{{.ReportedHandle}})<br>
<strong>Reported by:</strong> {{.ReporterFrom}}<br>
<strong>Category:</strong> {{.Category}}</p>
<p><strong>Reason:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Reason}}</pre>`))
func (s *SocialGraphService) notifyAdminsOfPostReport(reporter *model.User, post *model.Post, category, reason string) {
if len(s.admins) == 0 {
s.logger.Info("post report received but no admin recipients configured",
zap.String("post", post.ID))
return
}
from := "anonymous"
if reporter != nil {
from = fmt.Sprintf("%s (@%s)", reporter.Name, reporter.Handle)
}
author := post.AuthorName
if post.AuthorHandle != "" {
author = fmt.Sprintf("%s (@%s)", post.AuthorName, post.AuthorHandle)
}
if author == "" {
author = "unknown"
}
var buf bytes.Buffer
if err := postReportEmailTpl.Execute(&buf, map[string]any{
"PostID": post.ID,
"Author": author,
"ReporterFrom": from,
"Category": category,
"Reason": reason,
"Caption": post.Caption,
"SourceURL": post.SourceURL,
}); err != nil {
s.logger.Warn("render post-report email failed", zap.Error(err))
return
}
subject := fmt.Sprintf("[Post report · %s] %s", category, summarize(post.Caption, 50))
body := buf.String()
for _, to := range s.admins {
if err := s.mail.Send(to, subject, body); err != nil {
s.logger.Warn("post-report email send failed", zap.String("to", to), zap.Error(err))
}
}
}
var postReportEmailTpl = template.Must(template.New("postReport").Parse(`<p><strong>A post has been reported</strong></p>
<p><strong>Post ID:</strong> {{.PostID}}<br>
<strong>Author:</strong> {{.Author}}<br>
<strong>Reported by:</strong> {{.ReporterFrom}}<br>
<strong>Category:</strong> {{.Category}}</p>
{{if .Reason}}<p><strong>Reason:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Reason}}</pre>{{end}}
<p><strong>Caption:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Caption}}</pre>
<p style="color:#999;font-size:12px"><a href="{{.SourceURL}}">{{.SourceURL}}</a></p>`))
+516
View File
@@ -0,0 +1,516 @@
// 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). `viewerID` is "" for anonymous callers;
// when set, the returned post carries the viewer's reshare state so the
// frontend can render the right action button without a second request.
func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post, error) {
p, err := s.posts.FindByID(ctx, id)
if err != nil {
return nil, err
}
if err := s.hydrateReshareStates(ctx, viewerID, []*model.Post{p}); err != nil {
return nil, err
}
return p, nil
}
// Reshare materialises a reshare as a brand-new posts row owned by the
// caller, with reshared_from_post_id pointing to the canonical original.
// Idempotent — calling it twice returns the same reshare row. Resharing
// one's own post (canonical or chained) is rejected at the repo layer.
//
// Returns the new post so the frontend can drop it into local state
// without a round-trip.
func (s *PostService) Reshare(ctx context.Context, postID, callerID string) (*model.Post, error) {
return s.posts.Reshare(ctx, postID, callerID)
}
// Unreshare removes the caller's reshare of a canonical post by deleting
// their own reshare posts row. Idempotent — no-ops when nothing to delete.
func (s *PostService) Unreshare(ctx context.Context, postID, callerID string) error {
return s.posts.Unreshare(ctx, postID, callerID)
}
// hydrateReshareStates fills in ViewerHasReshared on each post via one
// batched lookup. Skips the query when the viewer is anonymous (the
// answer is uniformly false) or the slice is empty.
func (s *PostService) hydrateReshareStates(ctx context.Context, viewerID string, posts []*model.Post) error {
if viewerID == "" || len(posts) == 0 {
return nil
}
ids := make([]string, 0, len(posts))
for _, p := range posts {
if p == nil {
continue
}
ids = append(ids, p.ID)
}
if len(ids) == 0 {
return nil
}
states, err := s.posts.ReshareStatesFor(ctx, viewerID, ids)
if err != nil {
return err
}
for _, p := range posts {
if p == nil {
continue
}
if states[p.ID] {
p.ViewerHasReshared = true
}
}
return nil
}
// hydrateReshareStatesSlice is the value-slice variant of
// hydrateReshareStates. Repository list methods return []model.Post
// (not pointers) — we wrap each element so the helper can mutate in
// place via a stable pointer.
func (s *PostService) hydrateReshareStatesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
if len(posts) == 0 {
return nil
}
ptrs := make([]*model.Post, len(posts))
for i := range posts {
ptrs[i] = &posts[i]
}
return s.hydrateReshareStates(ctx, viewerID, ptrs)
}
// 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. Owner-only — every
// reshare is its own posts row, owned by the resharer, so this works
// uniformly for canonical posts and reshares.
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. `viewerID` is "" for anonymous
// callers; when set, each returned post carries the viewer's reshare
// state.
func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string, limit, offset int) ([]model.Post, int, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
if offset < 0 {
offset = 0
}
posts, total, err := s.posts.ListByHandle(ctx, handle, limit, offset)
if err != nil {
return nil, 0, err
}
if err := s.hydrateReshareStatesSlice(ctx, viewerID, posts); err != nil {
return nil, 0, err
}
return posts, total, nil
}
// ListMine is the owner's view of their own profile feed. Every post
// they own — authored or reshared — comes from posts.user_id, so the
// viewer is by construction the owner. ViewerHasReshared is true for
// reshare rows (the user IS the resharer) and false for canonical
// rows (you don't "reshare your own post").
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
}
posts, total, err := s.posts.ListByUser(ctx, userID, limit, offset)
if err != nil {
return nil, 0, err
}
for i := range posts {
if posts[i].ResharedFromPostID != nil {
posts[i].ViewerHasReshared = true
}
}
return posts, total, nil
}
// 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
}
// PostsIn returns posts without images (single-table boundary). Hydrate
// them in one batched query so callers — and the post cards rendered
// inside collection carousels — can show the post's first image as
// background instead of falling back to the empty quote panel.
if len(posts) > 0 {
ids := make([]string, len(posts))
for i := range posts {
ids[i] = posts[i].ID
}
imgs, err := s.posts.ImagesFor(ctx, ids)
if err != nil {
return nil, nil, err
}
for i := range posts {
posts[i].Images = imgs[posts[i].ID]
}
// Same batched approach for reshare states so the action footer
// on each card swaps Reshare↔Send correctly. Anonymous viewers
// (viewerID == "") get all-false at zero cost.
if viewerID != "" {
states, err := s.posts.ReshareStatesFor(ctx, viewerID, ids)
if err != nil {
return nil, nil, err
}
for i := range posts {
if states[posts[i].ID] {
posts[i].ViewerHasReshared = true
}
}
}
}
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
}
+247
View File
@@ -0,0 +1,247 @@
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.
// UpdateProfileInput is the partial profile patch a handler hands the
// service. Every field is a pointer so nil means "leave unchanged". The
// service validates/normalizes each present field, then maps to a
// repository.ProfileUpdate.
type UpdateProfileInput struct {
Name *string
AvatarURL *string
Handle *string
Country *string
Sampradaya *string
Gender *string
Initiated *bool
InitiatedAt *string
DailyChantingRounds *int
PreferredChantingTime *string
PeakActiveTime *string
}
// validGenders is the accepted set; "" clears the field.
var validGenders = map[string]bool{"male": true, "female": true, "other": true}
// validSampradayas is the accepted lineage set. Currently just Gaudiya
// (the default); add codes here as more lineages are supported.
var validSampradayas = map[string]bool{"gaudiya": true}
func (s *UserService) UpdateProfile(ctx context.Context, id string, in UpdateProfileInput) (*model.User, error) {
p := repository.ProfileUpdate{
AvatarURL: in.AvatarURL, // passthrough: nil=leave, ""=clear, x=set
Initiated: in.Initiated,
DailyChantingRounds: in.DailyChantingRounds,
}
if in.Name != nil {
trimmed := strings.TrimSpace(*in.Name)
if trimmed == "" {
return nil, errors.New("name cannot be empty")
}
if len(trimmed) > 120 {
return nil, errors.New("name too long (max 120)")
}
p.Name = &trimmed
}
if in.Handle != nil {
clean := normalizeHandle(*in.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)")
}
p.Handle = &clean
}
if in.Country != nil {
// "" clears; otherwise a 2-letter ISO-3166-1 alpha-2 code (the
// frontend dropdown only ever sends valid codes).
cc := strings.ToUpper(strings.TrimSpace(*in.Country))
if cc != "" && len(cc) != 2 {
return nil, errors.New("country must be a 2-letter code")
}
p.Country = &cc
}
if in.Sampradaya != nil {
// "" clears (optional field); otherwise must be a known lineage.
s := strings.ToLower(strings.TrimSpace(*in.Sampradaya))
if s != "" && !validSampradayas[s] {
return nil, errors.New("unknown sampradaya")
}
p.Sampradaya = &s
}
if in.Gender != nil {
g := strings.ToLower(strings.TrimSpace(*in.Gender))
if g != "" && !validGenders[g] {
return nil, errors.New("gender must be male, female, or other")
}
p.Gender = &g
}
if in.InitiatedAt != nil {
d := strings.TrimSpace(*in.InitiatedAt)
if d != "" {
if _, err := time.Parse("2006-01-02", d); err != nil {
return nil, errors.New("initiated_at must be a date (YYYY-MM-DD)")
}
}
p.InitiatedAt = &d
}
if in.DailyChantingRounds != nil {
// 0 is the clear sentinel; real values are 1–1008 (DB enforces too).
n := *in.DailyChantingRounds
if n != 0 && (n < 1 || n > 1008) {
return nil, errors.New("daily_chanting_rounds must be between 1 and 1008")
}
}
if in.PreferredChantingTime != nil {
t, err := normalizeClock(*in.PreferredChantingTime)
if err != nil {
return nil, errors.New("preferred_chanting_time must be HH:MM")
}
p.PreferredChantingTime = &t
}
if in.PeakActiveTime != nil {
t, err := normalizeClock(*in.PeakActiveTime)
if err != nil {
return nil, errors.New("peak_active_time must be HH:MM")
}
p.PeakActiveTime = &t
}
return s.users.UpdateProfile(ctx, id, p)
}
// normalizeClock validates an "HH:MM" 24-hour clock time, returning "" for
// a blank input (clear). The system-managed notification fields use this.
func normalizeClock(raw string) (string, error) {
t := strings.TrimSpace(raw)
if t == "" {
return "", nil
}
if _, err := time.Parse("15:04", t); err != nil {
return "", err
}
return t, nil
}
// Search returns users matching the query (handle or name substring).
// Used by the public user-search endpoint on the /search page.
func (s *UserService) Search(ctx context.Context, q string, limit int) ([]model.User, error) {
return s.users.Search(ctx, q, limit)
}
// GetReadingPrefs returns the user's reading prefs blob (opaque JSON).
// Used by the frontend to hydrate ReadingSettings on sign-in so the
// theme/palette/font choices roam across devices.
func (s *UserService) GetReadingPrefs(ctx context.Context, id string) ([]byte, error) {
return s.users.GetReadingPrefs(ctx, id)
}
// UpdateReadingPrefs overwrites the user's reading prefs blob. The
// handler re-marshals the decoded request before calling in here so we
// know `prefsJSON` is well-formed by the time it hits the DB.
func (s *UserService) UpdateReadingPrefs(ctx context.Context, id string, prefsJSON []byte) error {
return s.users.UpdateReadingPrefs(ctx, id, prefsJSON)
}
// 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();
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS reading_prefs;
@@ -0,0 +1,15 @@
-- Adds per-user reading preferences (theme palette, font family, font
-- size, justification toggle) so the prefs roam across devices once the
-- user signs in.
--
-- Stored as JSONB rather than a column-per-field — the shape is owned
-- by the frontend (components/reading-settings.tsx), changes
-- occasionally (new palettes added every now and then), and we never
-- query individual keys server-side. JSONB keeps the schema stable as
-- the shape evolves.
--
-- Default `'{}'::jsonb` so existing rows are not nulled and the
-- frontend can treat the server response as "merge into local defaults"
-- without an additional null check.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS reading_prefs JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_post_reshares_user_created;
DROP TABLE IF EXISTS post_reshares;
@@ -0,0 +1,25 @@
-- Tracks which posts a user has reshared. Pure join table — no
-- denormalised counters, no duplicate post rows. The act of resharing
-- is a relationship between (post, user); the post itself stays in
-- one canonical row owned by its original author.
--
-- This powers the post-card swap: posts a user has authored OR
-- reshared show the "Send" (external share) button instead of
-- "Reshare", because there's nothing left to bring onto the account.
--
-- Composite PK on (post_id, user_id) makes the relationship unique
-- and gives us a covered lookup for the "did this viewer reshare this
-- post?" check used in batched feed hydration.
--
-- Index on (user_id, created_at DESC) keeps "what has this user
-- reshared, newest first?" cheap for the day we surface that on the
-- profile feed.
CREATE TABLE IF NOT EXISTS post_reshares (
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_post_reshares_user_created
ON post_reshares (user_id, created_at DESC);
+2
View File
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_feedback_created;
DROP TABLE IF EXISTS feedback;
+33
View File
@@ -0,0 +1,33 @@
-- Feedback collects user-submitted bug reports, feature requests, and
-- general comments captured by the frontend's /feedback page. Two
-- consumers:
-- 1. The team — every insert fans out an HTML notification to every
-- address in FEEDBACK_ADMIN_EMAILS via the existing mailer.
-- 2. The /stats ops page — aggregate counts + the most-recent N rows
-- render together so we can scan recent feedback without having to
-- open the DB.
--
-- user_id is nullable (and ON DELETE SET NULL on cascade) because:
-- • Anonymous visitors submit feedback too — they fill in the optional
-- contact_email field instead of leaving an account behind.
-- • When a user later deletes their account we keep the row but
-- forget who sent it. The content stays useful as product signal
-- even after the author is gone.
--
-- contact_email is the soft reply-to surfaced only for the anonymous
-- case; the logged-in case derives reply-to from the joined user row's
-- email at admin-notification time.
--
-- Index on created_at DESC keeps the /stats "recent feedback" list
-- cheap as the table grows.
CREATE TABLE IF NOT EXISTS feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
category TEXT NOT NULL,
message TEXT NOT NULL,
contact_email TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_feedback_created
ON feedback (created_at DESC);
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS oauth_accounts;
-- Note: re-imposing NOT NULL on users.password_hash would require backfilling
-- any OAuth-only users with a dummy hash; left as a manual step.
@@ -0,0 +1,35 @@
-- OAuth account linkage.
--
-- One row per (provider, provider_user_id). A single user can have several
-- providers linked (e.g. signed up with email + later linked Google). The
-- provider's `sub` (Google) / `id` (Facebook) is the stable identity — email
-- is captured for display but is NOT the join key, since providers can let
-- users change the email on the account.
--
-- When a callback fires:
-- 1. Look up (provider, provider_user_id) — found → log that user in.
-- 2. Otherwise look up users.email — found → link new oauth_accounts row
-- to the existing user and log them in.
-- 3. Otherwise create a new user (password_hash NULL) + an oauth_accounts
-- row, log them in.
CREATE TABLE IF NOT EXISTS oauth_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(32) NOT NULL, -- "google" | "facebook"
provider_user_id TEXT NOT NULL, -- sub (Google) / id (Facebook)
email CITEXT,
name TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (provider, provider_user_id)
);
CREATE INDEX IF NOT EXISTS oauth_accounts_user_id_idx ON oauth_accounts (user_id);
CREATE INDEX IF NOT EXISTS oauth_accounts_provider_idx ON oauth_accounts (provider);
-- Password hash must be optional now: OAuth-only users have no password.
-- Existing email/password users keep their hash; new OAuth users get NULL
-- and can set a password later via the forgot-password flow if they want
-- a fallback login method.
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
@@ -0,0 +1,3 @@
-- Reverting this requires backfilling NULL emails with synthetic values
-- first; left as a manual step (see operational runbook).
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
@@ -0,0 +1,10 @@
-- Some auth providers (Facebook in particular) let users sign up without
-- linking a verified email — phone-only accounts and accounts whose owner
-- declined the `email` permission both fall under this. To accept those
-- users we need to allow NULL on users.email.
--
-- The UNIQUE constraint stays put: Postgres treats NULLs as distinct under
-- a regular UNIQUE index (multiple rows with NULL email are allowed) so
-- nothing breaks for OAuth-only users with no address on file. Real email
-- addresses are still de-duplicated, exactly as before.
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS post_reshare_images;
@@ -0,0 +1,35 @@
-- Per-reshare background image overrides.
--
-- Previously every viewer of a reshare saw the canonical post_images row —
-- so when either the original author or any resharer "Updated background"
-- the change leaked across all timelines that referenced the post. This
-- table separates "the post's background" from "this user's reshare of
-- the post's background": authoring stays in post_images, resharing
-- stores its own snapshot here.
--
-- At reshare time we copy the post's current post_images into this table
-- so the resharer's feed renders identically to the author's. After that
-- moment the two are independent — the resharer's background lives only
-- here, the original author's lives only in post_images, and changes to
-- one never touch the other.
--
-- Cascade: the FK targets the (post_id, user_id) primary key on
-- post_reshares. Every path that removes a reshare relationship (the
-- author deletes the post, the resharer un-reshares, either user is
-- deleted) drops the post_reshares row, which cascades the overrides
-- away. The user's stated rule — "should not be in sync except deletion"
-- — is preserved end-to-end without any extra application code.
CREATE TABLE IF NOT EXISTS post_reshare_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL,
user_id UUID NOT NULL,
url TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (post_id, user_id)
REFERENCES post_reshares (post_id, user_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_post_reshare_images_lookup
ON post_reshare_images (post_id, user_id, position);
@@ -0,0 +1,12 @@
-- Reverting this requires materialising the new reshared posts back into
-- the post_reshares + post_reshare_images join model and then dropping
-- the column. Documented but not automated — run manually if a rollback
-- is ever actually needed.
--
-- Step 1: recreate the old tables.
-- Step 2: insert one row per reshare back into post_reshares.
-- Step 3: copy each reshare's post_images back into post_reshare_images.
-- Step 4: delete the materialised reshare rows from `posts`.
-- Step 5: drop the column.
ALTER TABLE posts DROP COLUMN IF EXISTS reshared_from_post_id;
@@ -0,0 +1,80 @@
-- Reshares become real `posts` rows.
--
-- Previously a reshare was a join-table relationship between (post, user)
-- that pointed at the original author's canonical row. Likes, comments,
-- and (most recently) background overrides all had to be plumbed through
-- a parallel "per-resharer" path to avoid leaking across timelines. The
-- next set of social features (likes, comments) would multiply that
-- complexity again.
--
-- This migration collapses that model: every reshare is its own row in
-- `posts`, owned by the resharer, with a `reshared_from_post_id` pointer
-- back to the canonical original. Likes and comments will naturally hang
-- off post.id and diverge for free; backgrounds are already per-row in
-- post_images so the per-reshare override table goes away.
--
-- Deletion still cascades: the FK uses ON DELETE CASCADE on
-- reshared_from_post_id, so an author deleting their post nukes every
-- reshare of it (including their post_images via the existing FK).
-- 1. New self-referential column. Nullable: NULL on canonical posts,
-- set on reshares.
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS reshared_from_post_id UUID
REFERENCES posts(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_posts_reshared_from
ON posts (reshared_from_post_id)
WHERE reshared_from_post_id IS NOT NULL;
-- Composite index used by ReshareStatesFor — given a viewer + a batch of
-- canonical post ids, "which of these has the viewer reshared?".
CREATE INDEX IF NOT EXISTS idx_posts_user_reshared_from
ON posts (user_id, reshared_from_post_id)
WHERE reshared_from_post_id IS NOT NULL;
-- 2. Backfill: each row in post_reshares becomes a real `posts` row,
-- copying the canonical post's textual fields and the timestamp the
-- reshare originally happened. The new row is owned by the resharer.
--
-- Skipped if post_reshares doesn't exist (clean install) — the
-- DO block uses to_regclass so a fresh database doesn't error.
DO $$
BEGIN
IF to_regclass('public.post_reshares') IS NOT NULL THEN
INSERT INTO posts (
id, user_id, caption, source_url, source_title,
created_at, updated_at, reshared_from_post_id
)
SELECT
gen_random_uuid(),
r.user_id,
p.caption,
p.source_url,
p.source_title,
r.created_at,
r.created_at,
p.id
FROM post_reshares r
JOIN posts p ON p.id = r.post_id;
END IF;
END$$;
-- 3. Carry the per-resharer image overrides over. Each override row maps
-- to the newly-inserted post via (reshared_from_post_id, user_id).
DO $$
BEGIN
IF to_regclass('public.post_reshare_images') IS NOT NULL THEN
INSERT INTO post_images (post_id, url, position)
SELECT np.id, ri.url, ri.position
FROM post_reshare_images ri
JOIN posts np
ON np.reshared_from_post_id = ri.post_id
AND np.user_id = ri.user_id;
END IF;
END$$;
-- 4. Old tables are no longer referenced anywhere — drop them so the
-- schema mirrors the new model.
DROP TABLE IF EXISTS post_reshare_images;
DROP TABLE IF EXISTS post_reshares;
@@ -0,0 +1,5 @@
-- This is a data-only backfill — there's no clean rollback because we
-- can't distinguish post_images rows added by this migration from rows
-- the resharer added afterwards. Intentional no-op; documented for the
-- runbook only.
SELECT 1;
@@ -0,0 +1,29 @@
-- Backfill the "reshares inherit the canonical's images" rule for any
-- reshare row that landed in `posts` without an image set.
--
-- Two paths created such rows:
--
-- 1. Reshares made BEFORE migration 0014 had no per-resharer image
-- table to snapshot into, so when 0015 collapsed the old
-- post_reshares model into real posts rows there was nothing for
-- it to copy — those reshares now show with a blank background
-- where the original author has a cover image.
--
-- 2. Any future code path that creates a reshare row but forgets to
-- copy the images. Defensive — current `Reshare` in
-- internal/repository/post_repo.go does copy, but this migration
-- makes the database self-healing if we ever regress.
--
-- The copy is scoped to reshares with ZERO images of their own so we
-- never overwrite a resharer's explicit customization (including an
-- explicit "no background" — though that path didn't exist at the time
-- the affected rows were created, the guard keeps us safe for any
-- future re-runs of equivalent backfills).
INSERT INTO post_images (post_id, url, position)
SELECT r.id, i.url, i.position
FROM posts r
JOIN post_images i ON i.post_id = r.reshared_from_post_id
WHERE r.reshared_from_post_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM post_images WHERE post_id = r.id
);
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_feedback_unresolved;
ALTER TABLE feedback DROP COLUMN IF EXISTS resolved_at;
@@ -0,0 +1,19 @@
-- Track when a feedback row was resolved.
--
-- NULL = still open. Non-NULL = closed at that moment. The action is
-- one-way: the resolve endpoint enforces `WHERE resolved_at IS NULL` so
-- a row already marked stays marked, and a subsequent re-resolve is a
-- no-op (no new email goes out).
--
-- We don't store "who" resolved it because the /stats page surfaces
-- this to the team and any audit-quality "who closed what" trail would
-- want its own table — out of scope for now.
ALTER TABLE feedback
ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMPTZ;
-- Partial index makes "give me the still-open feedback" cheap as the
-- table grows. The /stats page lists newest-first regardless of state,
-- so the existing created_at index still serves its read path.
CREATE INDEX IF NOT EXISTS idx_feedback_unresolved
ON feedback (created_at DESC)
WHERE resolved_at IS NULL;
@@ -0,0 +1 @@
ALTER TABLE feedback DROP COLUMN IF EXISTS acknowledged_at;
@@ -0,0 +1,17 @@
-- Track when a feedback row was acknowledged.
--
-- Acknowledgement is the intermediate state between "open" and
-- "resolved": the team has seen the feedback and (if it needs work) the
-- devs have started on it. The author gets an email saying so, with a
-- promise of a second email once it's actually fixed/implemented.
--
-- NULL = not yet acknowledged. Non-NULL = acknowledged at that moment.
-- Like resolved_at, the action is one-way and idempotent: the endpoint
-- enforces `WHERE acknowledged_at IS NULL` so the acknowledgement email
-- fires at most once.
--
-- The two columns are independent stamps. An admin may acknowledge then
-- later resolve (two emails), or resolve directly without acknowledging
-- (one email). Resolving does not require a prior acknowledgement.
ALTER TABLE feedback
ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMPTZ;
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS notifications;
DROP TABLE IF EXISTS user_reports;
DROP TABLE IF EXISTS user_blocks;
DROP TABLE IF EXISTS follows;
@@ -0,0 +1,88 @@
-- Social graph: follows, blocks, reports, plus a generic notifications
-- table that backs the "notification plugin" (email today; websocket +
-- push will read the same rows later).
--
-- Follows are REQUEST-BASED. Clicking "Follow" inserts a row with
-- status='pending'; the target later accepts (status→'accepted',
-- responded_at stamped) or rejects (row deleted). A user is "following"
-- another only once the edge is 'accepted'. The UNIQUE(follower,
-- following) pair makes a repeated request idempotent, and the CHECK
-- stops anyone following themselves.
CREATE TABLE IF NOT EXISTS follows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending | accepted
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
responded_at TIMESTAMPTZ, -- set when accepted
UNIQUE (follower_id, following_id),
CHECK (follower_id <> following_id)
);
-- "Who follows me / who has requested me" — the incoming side. Filtered
-- by status so accepted-follower counts and pending-request lists are
-- both index-served.
CREATE INDEX IF NOT EXISTS follows_following_status_idx
ON follows (following_id, status);
-- "Who I follow / who I've requested" — the outgoing side.
CREATE INDEX IF NOT EXISTS follows_follower_status_idx
ON follows (follower_id, status);
-- Blocks. blocker_id has blocked blocked_id. One row per direction; the
-- service treats a block in EITHER direction as "these two can't follow
-- each other" and tears down any existing follow edges both ways.
CREATE TABLE IF NOT EXISTS user_blocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (blocker_id, blocked_id),
CHECK (blocker_id <> blocked_id)
);
-- Reverse lookup: "has anyone blocked me?" — used to compute blocked_by
-- when rendering a profile.
CREATE INDEX IF NOT EXISTS user_blocks_blocked_idx
ON user_blocks (blocked_id);
-- Reports. A reporter flags another user with a category + free-text
-- reason. resolved_at is one-way (admins clear it later); the partial
-- index keeps the open-reports queue cheap to list.
CREATE TABLE IF NOT EXISTS user_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reported_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category VARCHAR(32) NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ,
CHECK (reporter_id <> reported_id)
);
CREATE INDEX IF NOT EXISTS user_reports_reported_idx
ON user_reports (reported_id, created_at DESC);
CREATE INDEX IF NOT EXISTS user_reports_unresolved_idx
ON user_reports (created_at DESC) WHERE resolved_at IS NULL;
-- Notifications back the in-app/email/push fan-out. Today the
-- NotificationService persists a row here AND sends an email; websocket
-- + push delivery will later read unread rows for a user. actor_id is
-- the user who triggered the event (nullable for system notifications);
-- kind is a short machine string ('follow_request', 'follow_accepted').
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- recipient
actor_id UUID REFERENCES users(id) ON DELETE CASCADE, -- who caused it
kind VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
read_at TIMESTAMPTZ
);
-- "My notifications, newest first" + an unread filter for badge counts.
CREATE INDEX IF NOT EXISTS notifications_user_created_idx
ON notifications (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS notifications_user_unread_idx
ON notifications (user_id) WHERE read_at IS NULL;
@@ -0,0 +1,2 @@
ALTER TABLE users
DROP COLUMN IF EXISTS country;
+6
View File
@@ -0,0 +1,6 @@
-- Account country (ISO-3166-1 alpha-2), shown in the profile "About this
-- account" panel. Captured best-effort at signup (Cloudflare's
-- CF-IPCountry header when present, else a GeoIP lookup on the client IP)
-- and editable by the user from the profile edit form. NULL until known.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS country CHAR(2);
@@ -0,0 +1,8 @@
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_daily_chanting_rounds_range;
ALTER TABLE users
DROP COLUMN IF EXISTS gender,
DROP COLUMN IF EXISTS initiated,
DROP COLUMN IF EXISTS initiated_at,
DROP COLUMN IF EXISTS daily_chanting_rounds,
DROP COLUMN IF EXISTS preferred_chanting_time,
DROP COLUMN IF EXISTS peak_active_time;
@@ -0,0 +1,24 @@
-- Devotional profile details, plus two system-managed fields reserved for
-- the (not-yet-built) automated-notification scheduler.
--
-- gender / initiated / initiated_at / daily_chanting_rounds are
-- user-editable from the profile form. preferred_chanting_time and
-- peak_active_time are written silently by the backend later (no UI yet) —
-- both store a local clock time as "HH:MM" text so the scheduler can pick
-- a send window without timezone math at write time.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS gender VARCHAR(16),
ADD COLUMN IF NOT EXISTS initiated BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS initiated_at DATE,
ADD COLUMN IF NOT EXISTS daily_chanting_rounds INTEGER,
ADD COLUMN IF NOT EXISTS preferred_chanting_time VARCHAR(5),
ADD COLUMN IF NOT EXISTS peak_active_time VARCHAR(5);
-- Guard the chanting-round count to the meaningful range (1 round up to a
-- full lakh of names = 1008). NULL stays allowed (not specified).
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_daily_chanting_rounds_range;
ALTER TABLE users
ADD CONSTRAINT users_daily_chanting_rounds_range
CHECK (daily_chanting_rounds IS NULL
OR (daily_chanting_rounds BETWEEN 1 AND 1008));
@@ -0,0 +1,2 @@
ALTER TABLE users
DROP COLUMN IF EXISTS sampradaya;
@@ -0,0 +1,6 @@
-- Devotee's sampradāya (spiritual lineage). Every account defaults to the
-- Gaudiya sampradāya; the column is NOT NULL so the value is always
-- present. Stored as a lowercase code; the frontend maps it to a display
-- label. Existing rows are backfilled to 'gaudiya' by the DEFAULT.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS sampradaya VARCHAR(32) NOT NULL DEFAULT 'gaudiya';
@@ -0,0 +1,4 @@
-- Re-imposing NOT NULL requires backfilling any NULLs first.
UPDATE users SET sampradaya = 'gaudiya' WHERE sampradaya IS NULL;
ALTER TABLE users
ALTER COLUMN sampradaya SET NOT NULL;
@@ -0,0 +1,4 @@
-- Sampradāya is optional. Keep the 'gaudiya' DEFAULT so new accounts still
-- start in the Gaudiya lineage, but allow NULL so a devotee can clear it.
ALTER TABLE users
ALTER COLUMN sampradaya DROP NOT NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS post_reports;
@@ -0,0 +1,19 @@
-- Post reports — a viewer flags a specific post (not its author) with a
-- category + optional free-text reason. Mirrors user_reports but keyed to
-- a post; resolved_at is one-way (admins clear it later). The partial
-- index keeps the open-reports queue cheap to list.
CREATE TABLE IF NOT EXISTS post_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
category VARCHAR(32) NOT NULL,
reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS post_reports_post_idx
ON post_reports (post_id, created_at DESC);
CREATE INDEX IF NOT EXISTS post_reports_unresolved_idx
ON post_reports (created_at DESC) WHERE resolved_at IS NULL;
+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 }
+92
View File
@@ -0,0 +1,92 @@
// Package geoip resolves a request to an ISO-3166-1 alpha-2 country code,
// best-effort. Two sources, in priority order:
//
// 1. Cloudflare's CF-IPCountry header — instant, no network, present when
// the site is proxied through Cloudflare.
// 2. A GeoIP lookup on the client IP via the free ip-api.com endpoint —
// the fallback for when we're not behind Cloudflare (the current
// deployment is DNS-only).
//
// Everything is fire-and-forget: every failure path returns "" and the
// caller simply leaves the country unknown. Nothing here blocks a request
// on the critical path — callers run LookupCountryByIP from a goroutine.
package geoip
import (
"context"
"encoding/json"
"net"
"net/http"
"strings"
"time"
)
// CountryFromCloudflare reads the CF-IPCountry header. Cloudflare sets it
// to a 2-letter code, or to a sentinel ("XX" unknown, "T1" Tor) we treat
// as absent. Returns "" when the header isn't a usable country.
func CountryFromCloudflare(r *http.Request) string {
cc := strings.ToUpper(strings.TrimSpace(r.Header.Get("CF-IPCountry")))
if len(cc) != 2 || cc == "XX" || cc == "T1" {
return ""
}
return cc
}
// ClientIP returns the best guess at the caller's IP. The chi RealIP
// middleware already rewrites r.RemoteAddr from X-Forwarded-For /
// X-Real-IP, so we just strip the port.
func ClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err != nil {
// RemoteAddr may already be bare (no port) behind some proxies.
return strings.TrimSpace(r.RemoteAddr)
}
return host
}
// LookupCountryByIP resolves an IP to a country code via ip-api.com.
// Returns "" for empty / private / loopback IPs (no point asking) and on
// any error, timeout, or non-success response. Respects ctx for
// cancellation/timeout — callers should pass a short-timeout context.
func LookupCountryByIP(ctx context.Context, ip string) string {
ip = strings.TrimSpace(ip)
if ip == "" {
return ""
}
parsed := net.ParseIP(ip)
if parsed == nil || parsed.IsLoopback() || parsed.IsPrivate() ||
parsed.IsLinkLocalUnicast() || parsed.IsUnspecified() {
return ""
}
// Free tier is HTTP-only; countryCode keeps the payload tiny.
url := "http://ip-api.com/json/" + ip + "?fields=status,countryCode"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return ""
}
client := &http.Client{Timeout: 4 * time.Second}
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var body struct {
Status string `json:"status"`
CountryCode string `json:"countryCode"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return ""
}
if body.Status != "success" {
return ""
}
cc := strings.ToUpper(strings.TrimSpace(body.CountryCode))
if len(cc) != 2 {
return ""
}
return cc
}
+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
}
+280
View File
@@ -0,0 +1,280 @@
// Package oauth wraps the third-party sign-in providers used by the BFF.
//
// Design: each provider is a small struct that knows three things —
// - its golang.org/x/oauth2.Config (client id / secret / scopes / endpoint)
// - how to fetch a normalised Profile from an access token
// - its registry name ("google", "facebook")
//
// The handler layer is provider-agnostic: it picks a Registry entry by URL
// param, generates state, redirects to AuthCodeURL, then on callback
// exchanges the code and calls FetchProfile. The service layer then takes
// the Profile and turns it into a session (find-or-create user + JWT).
package oauth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"golang.org/x/oauth2"
googleEndpoint "golang.org/x/oauth2/google"
)
// Profile is the normalised shape we extract from every provider.
//
// Email may be empty when the user denied the email scope (rare on Google,
// possible on Facebook accounts with no verified email). Callers must treat
// an empty email as a hard error — we use email as a fallback link key.
type Profile struct {
Provider string // "google" | "facebook"
ProviderUserID string // sub (Google) / id (Facebook)
Email string // lowercased
EmailVerified bool // Google sets this; Facebook does not — default true
Name string // display name, may be empty
AvatarURL string // absolute URL, may be empty
}
// Provider is the per-vendor adapter. Stateless after construction.
type Provider interface {
Name() string
Config(redirectURL string) *oauth2.Config
// AuthCodeOptions returns extra parameters appended to AuthCodeURL.
// Used by Facebook to set `auth_type=rerequest` so a user who
// previously declined the email permission gets re-prompted on the
// next sign-in attempt (Facebook silently re-uses prior declines
// otherwise). Most providers return nil.
AuthCodeOptions() []oauth2.AuthCodeOption
FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error)
}
// Registry maps provider name → Provider. Built from config at server start;
// only providers with both client ID and secret set become available.
type Registry map[string]Provider
// NewRegistry builds the live registry. Missing creds for a provider simply
// omit it — the start handler returns 404 for unconfigured providers.
func NewRegistry(cfg Credentials) Registry {
r := Registry{}
if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" {
r["google"] = &google{id: cfg.GoogleClientID, secret: cfg.GoogleClientSecret}
}
if cfg.FacebookClientID != "" && cfg.FacebookClientSecret != "" {
r["facebook"] = &facebook{id: cfg.FacebookClientID, secret: cfg.FacebookClientSecret}
}
return r
}
// Credentials is the subset of app config the registry needs.
type Credentials struct {
GoogleClientID string
GoogleClientSecret string
FacebookClientID string
FacebookClientSecret string
}
// Has reports whether the registry knows the named provider.
func (r Registry) Has(name string) bool { _, ok := r[name]; return ok }
// Get returns the provider, or ErrUnknownProvider.
func (r Registry) Get(name string) (Provider, error) {
p, ok := r[name]
if !ok {
return nil, ErrUnknownProvider
}
return p, nil
}
// ErrUnknownProvider is returned when the URL param doesn't match any
// configured provider.
var ErrUnknownProvider = errors.New("unknown oauth provider")
// --- Google ---
type google struct{ id, secret string }
func (g *google) Name() string { return "google" }
func (g *google) Config(redirectURL string) *oauth2.Config {
return &oauth2.Config{
ClientID: g.id,
ClientSecret: g.secret,
RedirectURL: redirectURL,
Scopes: []string{"openid", "email", "profile"},
Endpoint: googleEndpoint.Endpoint,
}
}
func (g *google) AuthCodeOptions() []oauth2.AuthCodeOption { return nil }
// googleUserinfo is the subset of the v3 userinfo response we read. The
// full payload has more fields (locale, given_name, family_name) we don't
// currently need.
type googleUserinfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
}
func (g *google) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error) {
const url = "https://openidconnect.googleapis.com/v1/userinfo"
body, err := httpGet(ctx, tok, url)
if err != nil {
return nil, fmt.Errorf("google userinfo: %w", err)
}
var u googleUserinfo
if err := json.Unmarshal(body, &u); err != nil {
return nil, fmt.Errorf("google userinfo decode: %w", err)
}
if u.Sub == "" {
return nil, fmt.Errorf("google userinfo: empty sub")
}
return &Profile{
Provider: "google",
ProviderUserID: u.Sub,
Email: strings.ToLower(strings.TrimSpace(u.Email)),
EmailVerified: u.EmailVerified,
Name: strings.TrimSpace(u.Name),
AvatarURL: u.Picture,
}, nil
}
// --- Facebook ---
type facebook struct{ id, secret string }
func (f *facebook) Name() string { return "facebook" }
// Bump in lockstep with whatever version your Meta app is configured for
// (Settings → Basic → API Version). Stay on a single supported version so
// /me responses match the documented schema.
const facebookGraphVersion = "v25.0"
func (f *facebook) Config(redirectURL string) *oauth2.Config {
return &oauth2.Config{
ClientID: f.id,
ClientSecret: f.secret,
RedirectURL: redirectURL,
Scopes: []string{"email", "public_profile"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://www.facebook.com/" + facebookGraphVersion + "/dialog/oauth",
TokenURL: "https://graph.facebook.com/" + facebookGraphVersion + "/oauth/access_token",
},
}
}
// AuthCodeOptions: `auth_type=rerequest` forces Facebook to re-prompt for
// any permissions the user previously declined (specifically `email`).
// Without this, a user who unchecked Email on their first attempt would
// be stuck — Facebook silently re-grants the old decline forever.
func (f *facebook) AuthCodeOptions() []oauth2.AuthCodeOption {
return []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("auth_type", "rerequest"),
}
}
// facebookMe captures the fields requested via ?fields=id,email,name,picture.
// picture is an envelope: { data: { url, ... } }.
type facebookMe struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture struct {
Data struct {
URL string `json:"url"`
} `json:"data"`
} `json:"picture"`
}
func (f *facebook) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error) {
// Facebook Graph API: pass the token as ?access_token=... (their
// canonical mode) rather than via Bearer header. Bearer works most
// of the time but is documented as a secondary mode and has been
// reported to silently drop fields in some Graph versions.
params := url.Values{
"fields": {"id,email,name,picture.type(large)"},
"access_token": {tok.AccessToken},
}
reqURL := "https://graph.facebook.com/" + facebookGraphVersion +
"/me?" + params.Encode()
body, err := plainGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("facebook /me: %w", err)
}
var u facebookMe
if err := json.Unmarshal(body, &u); err != nil {
return nil, fmt.Errorf("facebook /me decode: %w", err)
}
if u.ID == "" {
return nil, fmt.Errorf("facebook /me: empty id")
}
// If email permission was granted but FB still didn't return one,
// log the raw response so the operator can diagnose (commonly the
// user's FB account email is unverified — Graph API omits it silently).
if u.Email == "" {
log.Printf("oauth/facebook: /me returned no email; raw=%s", truncate(body, 512))
}
return &Profile{
Provider: "facebook",
ProviderUserID: u.ID,
Email: strings.ToLower(strings.TrimSpace(u.Email)),
EmailVerified: u.Email != "", // Facebook returns an email only if user granted the scope; we trust it
Name: strings.TrimSpace(u.Name),
AvatarURL: u.Picture.Data.URL,
}, nil
}
func truncate(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}
// plainGet performs an unauthenticated GET (the caller is expected to have
// already embedded any access token in the URL). Used by Facebook because
// Graph API treats `?access_token=` as the canonical auth mode.
func plainGet(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}
// httpGet performs a Bearer-authenticated GET — used by Google. Facebook
// uses plainGet with the token in a query param instead.
func httpGet(ctx context.Context, tok *oauth2.Token, url string) ([]byte, error) {
cli := oauth2.NewClient(ctx, oauth2.StaticTokenSource(tok))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}
+103
View File
@@ -0,0 +1,103 @@
package oauth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// statePayload is the JSON shape we round-trip through the OAuth state
// parameter. The cookie holds the same encoded blob; on callback we compare
// the two byte-for-byte before trusting either.
//
// `Next` is the post-login redirect path on the frontend. We sign it (along
// with Nonce + IssuedAt) under HMAC so a tampered ?next= can't redirect the
// user to an arbitrary URL after sign-in.
//
// `LinkUserID` is set only by the /link/start endpoint — it pins the OAuth
// callback to the user who initiated the link. An empty value means the
// callback should run the normal sign-in / sign-up flow instead.
type statePayload struct {
Nonce string `json:"n"` // 16 random bytes, base64-urlencoded
Next string `json:"u"` // safe path, validated by handler
IssuedAt int64 `json:"t"` // unix seconds
Provider string `json:"p"` // "google" | "facebook"
LinkUserID string `json:"l,omitempty"` // user id to link the provider to, empty for sign-in flow
}
// stateMaxAge bounds how long a /start may be in flight. Ten minutes is
// generous — typical OAuth round-trips finish in seconds. Longer than this
// and we discard the state to keep the replay window small.
const stateMaxAge = 10 * time.Minute
// EncodeState signs and packs a fresh state value bound to the given provider
// and post-login redirect path. The returned string is what goes into both
// the `state` query param and the cookie.
//
// `linkUserID` pins the callback to a specific user (link-to-current-user
// flow). Pass "" for the normal sign-in flow.
func EncodeState(secret []byte, provider, next, linkUserID string) (string, error) {
nonceB := make([]byte, 16)
if _, err := rand.Read(nonceB); err != nil {
return "", fmt.Errorf("state nonce: %w", err)
}
p := statePayload{
Nonce: base64.RawURLEncoding.EncodeToString(nonceB),
Next: next,
IssuedAt: time.Now().Unix(),
Provider: provider,
LinkUserID: linkUserID,
}
body, err := json.Marshal(p)
if err != nil {
return "", err
}
bodyEnc := base64.RawURLEncoding.EncodeToString(body)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(bodyEnc))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return bodyEnc + "." + sig, nil
}
// DecodeState verifies the signature, checks the freshness window, and
// returns the embedded payload. Any failure surfaces as ErrInvalidState so
// the handler maps it to a single error path.
//
// `linkUserID` is empty for sign-in flows and set for link-to-current-user
// flows — callers should branch on it.
func DecodeState(secret []byte, raw string) (provider, next, linkUserID string, err error) {
parts := strings.SplitN(raw, ".", 2)
if len(parts) != 2 {
return "", "", "", ErrInvalidState
}
bodyEnc, sigEnc := parts[0], parts[1]
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(bodyEnc))
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(sigEnc)) {
return "", "", "", ErrInvalidState
}
body, err := base64.RawURLEncoding.DecodeString(bodyEnc)
if err != nil {
return "", "", "", ErrInvalidState
}
var p statePayload
if err := json.Unmarshal(body, &p); err != nil {
return "", "", "", ErrInvalidState
}
if time.Since(time.Unix(p.IssuedAt, 0)) > stateMaxAge {
return "", "", "", ErrInvalidState
}
return p.Provider, p.Next, p.LinkUserID, nil
}
// ErrInvalidState is returned whenever signature or freshness checks fail.
// We deliberately return one error so handlers don't leak whether a request
// was tampered or just stale.
var ErrInvalidState = errors.New("invalid or expired oauth state")