This commit is contained in:
Krispire Admin
2026-05-30 02:33:57 +05:30
parent 5e0a484239
commit 97422c1ef5
21 changed files with 1720 additions and 82 deletions
+23 -11
View File
@@ -9,19 +9,28 @@ 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)
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM="Prabhupāda Says <noreply@prabhupadasays.org>"
# Push Notifications # Push Notifications
FCM_CREDENTIALS_FILE= FCM_CREDENTIALS_FILE=
@@ -32,7 +41,10 @@ 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
+22 -3
View File
@@ -15,7 +15,26 @@ require (
require ( require (
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 v1.41.9 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect
github.com/aws/smithy-go v1.26.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/snappy v1.0.0 // indirect github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.5.0 // indirect github.com/google/uuid v1.5.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -30,7 +49,7 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect 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/crypto v0.52.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.29.0 // indirect golang.org/x/text v0.37.0 // indirect
) )
+45
View File
@@ -1,6 +1,42 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/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/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/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 +51,8 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/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=
@@ -81,6 +119,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/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=
@@ -89,6 +129,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-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=
@@ -96,6 +138,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-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=
@@ -104,6 +147,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-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=
+56 -19
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
@@ -28,33 +29,46 @@ 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 and email-verification mails.
FCMCredentialsFile string // path to Firebase service account JSON SMTPHost string
APNsKeyFile string // path to APNs auth key SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPFrom string // "Prabhupāda Says <noreply@prabhupadasays.org>"
// 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
} }
// 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 +83,17 @@ 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"),
FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""), FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""),
APNsKeyFile: getEnv("APNS_KEY_FILE", ""), APNsKeyFile: getEnv("APNS_KEY_FILE", ""),
@@ -82,15 +102,23 @@ 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"),
} }
// 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 +138,12 @@ 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
}
+221
View File
@@ -0,0 +1,221 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/response"
"github.com/prabhupadasays/spsays-bff/pkg/validator"
)
// AuthHandler exposes the auth-related HTTP endpoints.
//
// Every handler is a 5-line shim: parse → validate → service → render.
// All branching (already exists? wrong password? expired link?) lives in
// AuthService and surfaces here only as sentinel errors mapped to status
// codes via mapAuthErr.
type AuthHandler struct {
svc *service.AuthService
cookies auth.CookieOptions
}
func NewAuthHandler(svc *service.AuthService, cookies auth.CookieOptions) *AuthHandler {
return &AuthHandler{svc: svc, cookies: cookies}
}
// --- request shapes ---
type signUpReq struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
type logInReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
type forgotReq struct {
Email string `json:"email"`
}
type resetReq struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
type verifyReq struct {
Token string `json:"token"`
}
// --- handlers ---
// SignUp creates a user, plants the session cookie, returns the user.
func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
var req signUpReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "email", req.Email)
validator.Email(v, "email", req.Email)
validator.Required(v, "password", req.Password)
validator.MinLength(v, "password", req.Password, 8)
validator.Required(v, "name", req.Name)
validator.MaxLength(v, "name", req.Name, 120)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
res, err := h.svc.SignUp(r.Context(), req.Email, req.Password, req.Name)
if err != nil {
h.renderAuthErr(w, err)
return
}
auth.Set(w, res.Token, res.Expires, h.cookies)
response.JSON(w, http.StatusCreated, res.User)
}
// LogIn authenticates an existing user.
func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
var req logInReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "email", req.Email)
validator.Required(v, "password", req.Password)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
res, err := h.svc.LogIn(r.Context(), req.Email, req.Password)
if err != nil {
h.renderAuthErr(w, err)
return
}
auth.Set(w, res.Token, res.Expires, h.cookies)
response.JSON(w, http.StatusOK, res.User)
}
// LogOut just clears the cookie. JWTs remain technically valid until expiry,
// but with the cookie gone the browser can't present them.
func (h *AuthHandler) LogOut(w http.ResponseWriter, r *http.Request) {
auth.Clear(w, h.cookies)
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// Me returns the authenticated user. Protected by middleware.RequireAuth.
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
tv := middleware.TokenVersion(r.Context())
u, err := h.svc.Me(r.Context(), uid, tv)
if err != nil {
response.Error(w, http.StatusUnauthorized, "not authenticated")
return
}
response.JSON(w, http.StatusOK, u)
}
// ForgotPassword emails a reset link if the address exists. We always return
// 200 to avoid revealing whether the email is registered.
func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
var req forgotReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "email", req.Email)
validator.Email(v, "email", req.Email)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
if err := h.svc.RequestPasswordReset(r.Context(), req.Email); err != nil {
response.Error(w, http.StatusInternalServerError, "could not process request")
return
}
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ResetPassword consumes a reset token and sets a new password.
func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
var req resetReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "token", req.Token)
validator.Required(v, "new_password", req.NewPassword)
validator.MinLength(v, "new_password", req.NewPassword, 8)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
if err := h.svc.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil {
h.renderAuthErr(w, err)
return
}
// Force the user to log in again after a reset (their token_version is now stale).
auth.Clear(w, h.cookies)
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// VerifyEmail flips email_verified_at after the user clicks the link.
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
var req verifyReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.Token == "" {
response.Error(w, http.StatusBadRequest, "token is required")
return
}
if err := h.svc.VerifyEmail(r.Context(), req.Token); err != nil {
h.renderAuthErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ResendVerification — protected — refires the verification email for the
// currently-logged-in user.
func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.ResendVerification(r.Context(), uid); err != nil {
h.renderAuthErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// --- error mapping ---
func (h *AuthHandler) renderAuthErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, service.ErrInvalidCredentials),
errors.Is(err, service.ErrInvalidOrExpiredLink):
response.Error(w, http.StatusUnauthorized, err.Error())
case errors.Is(err, service.ErrEmailAlreadyTaken):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, service.ErrEmailAlreadyVerified),
errors.Is(err, service.ErrWeakPassword):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrUserNotFound):
response.Error(w, http.StatusNotFound, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "something went wrong")
}
}
+44 -10
View File
@@ -147,6 +147,12 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
limit = 100 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"
}
+87
View File
@@ -0,0 +1,87 @@
package handler
import (
"encoding/json"
"net/http"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"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"`
}
// UpdateProfile patches name and/or avatar.
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req updateProfileReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
u, err := h.svc.UpdateProfile(r.Context(), uid, req.Name, req.AvatarURL)
if err != nil {
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusOK, u)
}
type presignReq struct {
ContentType string `json:"content_type"`
}
type presignResp struct {
UploadURL string `json:"upload_url"`
PublicURL string `json:"public_url"`
}
// AvatarPresign returns a one-shot R2 PUT URL the browser uses to upload.
// The frontend then PATCHes /users/me with the returned public_url to
// persist the new avatar on the user row.
func (h *UserHandler) AvatarPresign(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req presignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
ct := strings.TrimSpace(req.ContentType)
if ct == "" {
response.Error(w, http.StatusBadRequest, "content_type is required")
return
}
upload, public, err := h.svc.AvatarPresign(r.Context(), uid, ct)
if err != nil {
// Differentiate "feature off" from "bad content type" so the frontend
// can show a useful message.
if strings.Contains(err.Error(), "not configured") {
response.Error(w, http.StatusServiceUnavailable, err.Error())
return
}
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusOK, presignResp{UploadURL: upload, PublicURL: public})
}
+48 -26
View File
@@ -2,56 +2,78 @@ package middleware
import ( 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. // UserID returns the authenticated user's id from the request context.
func GetUserID(ctx context.Context) string { // Returns "" if RequireAuth has not run.
if v, ok := ctx.Value(UserIDKey).(string); ok { 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 -10
View File
@@ -2,24 +2,51 @@ package model
import "time" import "time"
// User represents a user in PostgreSQL. // User represents a row in the `users` table.
//
// PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling
// even if a caller accidentally passes a User through response.JSON.
//
// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null`
// when the user has not yet verified, instead of an omitted field — the
// frontend uses this to decide whether to show the "verify your email" ribbon.
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
Avatar *string `json:"avatar,omitempty"` AvatarURL *string `json:"avatar_url"`
Role string `json:"role"` // "user" | "admin" | "moderator" Role string `json:"role"`
CreatedAt time.Time `json:"created_at"` EmailVerifiedAt *time.Time `json:"email_verified_at"`
UpdatedAt time.Time `json:"updated_at"` PasswordHash string `json:"-"`
TokenVersion int `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
// AuthToken represents a row in the `auth_tokens` table.
// Used for both password reset and email verification flows.
type AuthToken struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Purpose string `json:"purpose"` // "password_reset" | "email_verification"
TokenHash string `json:"-"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
const (
PurposePasswordReset = "password_reset"
PurposeEmailVerification = "email_verification"
)
// Bookmark represents a user's bookmarked content. // Bookmark represents a user's bookmarked content.
type Bookmark struct { type Bookmark struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
ContentType string `json:"content_type"` // "verse" | "letter" | "essay" | "transcription" | etc. ContentType string `json:"content_type"`
ContentID string `json:"content_id"` ContentID string `json:"content_id"`
ContentRef string `json:"content_ref"` // human-readable reference (e.g., "BG 1.1") ContentRef string `json:"content_ref"`
SelectedText *string `json:"selected_text,omitempty"` SelectedText *string `json:"selected_text,omitempty"`
Note *string `json:"note,omitempty"` Note *string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
+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
}
+227
View File
@@ -0,0 +1,227 @@
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")
// UserRepo provides CRUD on the users table.
type UserRepo struct {
pg *Postgres
}
func NewUserRepo(pg *Postgres) *UserRepo {
return &UserRepo{pg: pg}
}
// Create inserts a new user. Returns ErrEmailTaken on unique-violation.
// `email` is lowercased before insert; the column is CITEXT so case doesn't
// matter for lookups, but lower-casing keeps stored values tidy.
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name string) (*model.User, error) {
const q = `
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, password_hash, name, avatar_url, role,
email_verified_at, token_version, created_at, updated_at`
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q, strings.ToLower(strings.TrimSpace(email)), passwordHash, name).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if isUniqueViolation(err, "users_email_key") {
return nil, ErrEmailTaken
}
return nil, fmt.Errorf("create user: %w", err)
}
return u, nil
}
// FindByEmail returns the user with the given email, or ErrUserNotFound.
func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User, error) {
return r.findOne(ctx,
`WHERE email = $1`, strings.ToLower(strings.TrimSpace(email)))
}
// FindByID returns the user with the given id, or ErrUserNotFound.
func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) {
return r.findOne(ctx, `WHERE id = $1`, id)
}
func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) {
q := `
SELECT id, email, password_hash, name, avatar_url, role,
email_verified_at, token_version, created_at, updated_at
FROM users ` + where + ` LIMIT 1`
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q, args...).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("find user: %w", err)
}
return u, nil
}
// UpdateProfile updates the user's name and/or avatar. Pass nil to leave a field unchanged.
// Returns the updated user.
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) {
const q = `
UPDATE users
SET name = COALESCE($2, name),
avatar_url = CASE WHEN $3::boolean THEN $4 ELSE avatar_url END,
updated_at = now()
WHERE id = $1
RETURNING id, email, password_hash, name, avatar_url, role,
email_verified_at, token_version, created_at, updated_at`
// $3 is a flag: "did the caller intend to set avatar_url (including to NULL)?"
// If avatarURL is nil we leave it alone; otherwise we apply *avatarURL (which
// may itself be the empty string — caller decides if "" should map to NULL).
updateAvatar := avatarURL != nil
var avatarVal *string
if updateAvatar && *avatarURL != "" {
avatarVal = avatarURL
}
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("update profile: %w", err)
}
return u, nil
}
// UpdatePassword sets a new password hash and bumps token_version so any
// existing JWTs immediately become invalid.
func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error {
const q = `
UPDATE users
SET password_hash = $2,
token_version = token_version + 1,
updated_at = now()
WHERE id = $1`
tag, err := r.pg.Pool.Exec(ctx, q, id, newHash)
if err != nil {
return fmt.Errorf("update password: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrUserNotFound
}
return nil
}
// MarkEmailVerified sets email_verified_at = now() if not already set.
func (r *UserRepo) MarkEmailVerified(ctx context.Context, id string) error {
const q = `UPDATE users SET email_verified_at = now(), updated_at = now()
WHERE id = $1 AND email_verified_at IS NULL`
_, err := r.pg.Pool.Exec(ctx, q, id)
if err != nil {
return fmt.Errorf("mark verified: %w", err)
}
return nil
}
// Helper for unique-violation detection. Avoids importing pgconn just for
// the SQLSTATE; we string-match on the message which contains the constraint
// name. Good enough for the one constraint we care about.
func isUniqueViolation(err error, constraint string) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "23505") || strings.Contains(msg, constraint)
}
// AuthTokenRepo handles password_reset + email_verification tokens.
type AuthTokenRepo struct {
pg *Postgres
}
func NewAuthTokenRepo(pg *Postgres) *AuthTokenRepo {
return &AuthTokenRepo{pg: pg}
}
// Create inserts a new token row. `tokenHash` must be the SHA-256 hex of the
// raw token; only the hash is stored, never the plaintext.
func (r *AuthTokenRepo) Create(ctx context.Context, userID, purpose, tokenHash string, ttl time.Duration) (*model.AuthToken, error) {
const q = `
INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, purpose, token_hash, expires_at, used_at, created_at`
t := &model.AuthToken{}
err := r.pg.Pool.QueryRow(ctx, q, userID, purpose, tokenHash, time.Now().Add(ttl)).
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
if err != nil {
return nil, fmt.Errorf("create auth_token: %w", err)
}
return t, nil
}
// FindValid returns a non-used, non-expired token by hash + purpose.
func (r *AuthTokenRepo) FindValid(ctx context.Context, tokenHash, purpose string) (*model.AuthToken, error) {
const q = `
SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at
FROM auth_tokens
WHERE token_hash = $1
AND purpose = $2
AND used_at IS NULL
AND expires_at > now()
LIMIT 1`
t := &model.AuthToken{}
err := r.pg.Pool.QueryRow(ctx, q, tokenHash, purpose).
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("find token: %w", err)
}
return t, nil
}
// Consume marks a token as used. After this call the token can never be
// honoured again, even if it has not yet expired.
func (r *AuthTokenRepo) Consume(ctx context.Context, id string) error {
const q = `UPDATE auth_tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL`
_, err := r.pg.Pool.Exec(ctx, q, id)
if err != nil {
return fmt.Errorf("consume token: %w", err)
}
return nil
}
// InvalidatePending marks all not-yet-used tokens for a user+purpose as used.
// Called when a fresh token is issued so old links are immediately dead.
func (r *AuthTokenRepo) InvalidatePending(ctx context.Context, userID, purpose string) error {
const q = `UPDATE auth_tokens SET used_at = now()
WHERE user_id = $1 AND purpose = $2 AND used_at IS NULL`
_, err := r.pg.Pool.Exec(ctx, q, userID, purpose)
if err != nil {
return fmt.Errorf("invalidate pending tokens: %w", err)
}
return nil
}
+100 -3
View File
@@ -12,7 +12,10 @@ 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/shorturl" "github.com/prabhupadasays/spsays-bff/pkg/shorturl"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -41,6 +44,12 @@ type Server struct {
transcriptionH *handler.TranscriptionHandler transcriptionH *handler.TranscriptionHandler
searchH *handler.SearchHandler searchH *handler.SearchHandler
shortURLH *handler.ShortURLHandler shortURLH *handler.ShortURLHandler
authH *handler.AuthHandler
userH *handler.UserHandler
// 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 +95,61 @@ 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)
r2, err := repository.NewR2(repository.R2Config{
AccountID: cfg.R2AccountID,
AccessKeyID: cfg.R2AccessKeyID,
SecretAccessKey: cfg.R2SecretAccessKey,
Bucket: cfg.R2Bucket,
PublicURL: cfg.R2PublicURL,
})
if err != nil {
logger.Warn("auth: R2 unavailable — avatar uploads will return 503", zap.Error(err))
}
if r2 == nil {
logger.Info("auth: R2 not configured — avatar presign endpoint will return 503")
}
issuer := auth.NewIssuer(cfg.JWTSecret, cfg.JWTExpiration)
mail := mailer.New(mailer.Config{
Host: cfg.SMTPHost,
Port: cfg.SMTPPort,
Username: cfg.SMTPUsername,
Password: cfg.SMTPPassword,
From: cfg.SMTPFrom,
}, logger)
authSvc := service.NewAuthService(userRepo, tokenRepo, issuer, mail, logger, cfg.FrontendURL)
userSvc := service.NewUserService(userRepo, r2)
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)
}
// 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 +212,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.
@@ -367,12 +436,40 @@ 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)
return
}
r.Post("/signup", s.authH.SignUp)
r.Post("/login", s.authH.LogIn)
r.Post("/logout", s.authH.LogOut)
r.Post("/forgot-password", s.authH.ForgotPassword)
r.Post("/reset-password", s.authH.ResetPassword)
r.Post("/verify-email", s.authH.VerifyEmail)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/me", s.authH.Me)
r.Post("/resend-verification", s.authH.ResendVerification)
})
}) })
// User profile + avatar — all protected.
r.Route("/users", func(r chi.Router) { r.Route("/users", func(r chi.Router) {
// TODO: register user handlers if s.userH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Patch("/me", s.userH.UpdateProfile)
r.Post("/me/avatar-presign", s.userH.AvatarPresign)
})
}) })
r.Route("/collections", func(r chi.Router) { r.Route("/collections", func(r chi.Router) {
// TODO: register collection/bookmark handlers // TODO: register collection/bookmark handlers
+301
View File
@@ -0,0 +1,301 @@
// Package service orchestrates the auth flows on top of the repositories.
//
// The handler layer is intentionally dumb: it parses the request, calls one
// AuthService method, and renders the result. All branching (already exists?
// wrong password? expired token?) lives here, behind a small set of named
// error sentinels.
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
// Sentinel errors. Handlers map these to HTTP status codes.
var (
ErrInvalidCredentials = errors.New("invalid email or password")
ErrEmailAlreadyTaken = errors.New("email already registered")
ErrUserNotFound = errors.New("user not found")
ErrInvalidOrExpiredLink = errors.New("link is invalid or has expired")
ErrEmailAlreadyVerified = errors.New("email already verified")
ErrWeakPassword = errors.New("password must be at least 8 characters")
)
// AuthService bundles every flow that mutates auth state.
type AuthService struct {
users *repository.UserRepo
tokens *repository.AuthTokenRepo
jwt *auth.Issuer
mail mailer.Mailer
logger *zap.Logger
frontend string // FrontendURL — used to build reset / verify links
// TTLs — surfaced as fields so tests can shrink them.
PasswordResetTTL time.Duration
EmailVerifyTTL time.Duration
}
// NewAuthService wires the dependencies.
func NewAuthService(
users *repository.UserRepo,
tokens *repository.AuthTokenRepo,
jwt *auth.Issuer,
mail mailer.Mailer,
logger *zap.Logger,
frontendURL string,
) *AuthService {
return &AuthService{
users: users,
tokens: tokens,
jwt: jwt,
mail: mail,
logger: logger,
frontend: strings.TrimRight(frontendURL, "/"),
PasswordResetTTL: 1 * time.Hour,
EmailVerifyTTL: 24 * time.Hour,
}
}
// AuthResult is the value handlers return on signup/login. Token is the raw
// JWT the handler will plant in the response cookie; the User is the public
// profile to ship in the response body.
type AuthResult struct {
User *model.User
Token string
Expires time.Time
}
// SignUp creates a new user, issues a session JWT, and fires off a
// verification email (best-effort — failure to send does not block signup,
// since the user can hit resend later).
func (s *AuthService) SignUp(ctx context.Context, email, password, name string) (*AuthResult, error) {
if len(password) < 8 {
return nil, ErrWeakPassword
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
u, err := s.users.Create(ctx, email, string(hash), strings.TrimSpace(name))
if err != nil {
if errors.Is(err, repository.ErrEmailTaken) {
return nil, ErrEmailAlreadyTaken
}
return nil, err
}
if err := s.sendVerificationEmail(ctx, u); err != nil {
// Log and keep going — signup itself succeeded.
s.logger.Warn("verification email failed", zap.String("user", u.ID), zap.Error(err))
}
return s.issueSession(u)
}
// LogIn authenticates a user by email + password. The error returned for an
// unknown email is intentionally identical to a wrong password — never leak
// which one was wrong.
func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthResult, error) {
u, err := s.users.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
return nil, ErrInvalidCredentials
}
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return s.issueSession(u)
}
// Me looks up a user by id and re-verifies the JWT's token_version against
// the stored value. Mismatch means the user has logged-out-everywhere or
// changed password since this JWT was issued → reject.
func (s *AuthService) Me(ctx context.Context, userID string, tokenVersion int) (*model.User, error) {
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, ErrUserNotFound
}
if u.TokenVersion != tokenVersion {
return nil, auth.ErrInvalidToken
}
return u, nil
}
// RequestPasswordReset always returns nil to callers — we don't want to
// reveal whether the email is registered. If the email exists we mint a
// token and email a link; otherwise we silently no-op.
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
u, err := s.users.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
return nil // do not reveal
}
return err
}
// Invalidate any previous reset tokens for this user.
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposePasswordReset)
raw, hash, err := newToken()
if err != nil {
return err
}
if _, err := s.tokens.Create(ctx, u.ID, model.PurposePasswordReset, hash, s.PasswordResetTTL); err != nil {
return err
}
link := fmt.Sprintf("%s/reset-password?token=%s", s.frontend, raw)
body := passwordResetEmail(u.Name, link)
if err := s.mail.Send(u.Email, "Reset your Prabhupāda Says password", body); err != nil {
s.logger.Warn("password reset email failed", zap.String("user", u.ID), zap.Error(err))
}
return nil
}
// ResetPassword consumes a reset token and sets a new password.
// On success the user's token_version is bumped, invalidating every
// outstanding JWT (so a stolen token can't be used after the legit owner
// changes their password).
func (s *AuthService) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
if len(newPassword) < 8 {
return ErrWeakPassword
}
hash := hashToken(rawToken)
t, err := s.tokens.FindValid(ctx, hash, model.PurposePasswordReset)
if err != nil {
return ErrInvalidOrExpiredLink
}
bcryptHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if err := s.users.UpdatePassword(ctx, t.UserID, string(bcryptHash)); err != nil {
return err
}
if err := s.tokens.Consume(ctx, t.ID); err != nil {
s.logger.Warn("consume reset token", zap.String("token_id", t.ID), zap.Error(err))
}
return nil
}
// VerifyEmail consumes an email-verification token and flips the user's
// email_verified_at timestamp.
func (s *AuthService) VerifyEmail(ctx context.Context, rawToken string) error {
hash := hashToken(rawToken)
t, err := s.tokens.FindValid(ctx, hash, model.PurposeEmailVerification)
if err != nil {
return ErrInvalidOrExpiredLink
}
if err := s.users.MarkEmailVerified(ctx, t.UserID); err != nil {
return err
}
if err := s.tokens.Consume(ctx, t.ID); err != nil {
s.logger.Warn("consume verify token", zap.String("token_id", t.ID), zap.Error(err))
}
return nil
}
// ResendVerification re-fires the verification email for the currently-logged-in
// user. No-ops with no error if the user is already verified.
func (s *AuthService) ResendVerification(ctx context.Context, userID string) error {
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return ErrUserNotFound
}
if u.EmailVerifiedAt != nil {
return ErrEmailAlreadyVerified
}
return s.sendVerificationEmail(ctx, u)
}
// --- internal helpers ---
func (s *AuthService) issueSession(u *model.User) (*AuthResult, error) {
tok, exp, err := s.jwt.Issue(u.ID, u.TokenVersion)
if err != nil {
return nil, err
}
return &AuthResult{User: u, Token: tok, Expires: exp}, nil
}
func (s *AuthService) sendVerificationEmail(ctx context.Context, u *model.User) error {
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeEmailVerification)
raw, hash, err := newToken()
if err != nil {
return err
}
if _, err := s.tokens.Create(ctx, u.ID, model.PurposeEmailVerification, hash, s.EmailVerifyTTL); err != nil {
return err
}
link := fmt.Sprintf("%s/verify-email?token=%s", s.frontend, raw)
body := verifyEmailMessage(u.Name, link)
return s.mail.Send(u.Email, "Verify your email — Prabhupāda Says", body)
}
// newToken returns (rawHex, sha256HexHash). The raw value goes in the
// emailed link; only the hash is stored in the DB.
func newToken() (raw, hash string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
raw = hex.EncodeToString(b)
hash = hashToken(raw)
return raw, hash, nil
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// --- email bodies (intentionally minimal HTML; auth mails should be plain) ---
func passwordResetEmail(name, link string) string {
return fmt.Sprintf(`<p>Hari Bol %s,</p>
<p>You (or someone using your email) asked to reset your password on
<strong>Prabhupāda Says</strong>. Click the link below to set a new one:</p>
<p><a href="%s">%s</a></p>
<p>This link expires in 1 hour. If you didn't request a reset, just ignore
this email — your password stays the same.</p>`, htmlEscape(name), link, link)
}
func verifyEmailMessage(name, link string) string {
return fmt.Sprintf(`<p>Hari Bol %s,</p>
<p>Welcome to <strong>Prabhupāda Says</strong>. Please confirm your email by
clicking the link below:</p>
<p><a href="%s">%s</a></p>
<p>This link expires in 24 hours. You can request a new one from the banner
shown at the top of the site after you log in.</p>`, htmlEscape(name), link, link)
}
func htmlEscape(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
)
return r.Replace(s)
}
+97
View File
@@ -0,0 +1,97 @@
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 and/or avatar URL.
//
// `avatarURL` semantics:
//
// nil → don't touch
// "" → clear (remove avatar)
// "x" → set to "x"
//
// The avatar URL is expected to point at our R2 bucket — we don't validate
// the host because we don't want to break tests / local setups, but in
// production you could add a check that it starts with R2_PUBLIC_URL.
func (s *UserService) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) {
if name != nil {
trimmed := strings.TrimSpace(*name)
if trimmed == "" {
return nil, errors.New("name cannot be empty")
}
if len(trimmed) > 120 {
return nil, errors.New("name too long (max 120)")
}
name = &trimmed
}
return s.users.UpdateProfile(ctx, id, name, avatarURL)
}
// 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);
+56
View File
@@ -0,0 +1,56 @@
package auth
import (
"net/http"
"time"
)
// CookieName is the single httpOnly cookie that carries the JWT.
const CookieName = "spsays_auth"
// CookieOptions captures the per-environment knobs for the auth cookie.
type CookieOptions struct {
Domain string // empty in dev; ".prabhupadasays.org" in prod for subdomain sharing
Secure bool // true in production (HTTPS-only)
}
// Set writes the JWT into a Secure, httpOnly, SameSite=Lax cookie.
// SameSite=Lax + same-registrable-domain (prabhupadasays.org / api.prabhupadasays.org)
// is enough for cross-subdomain auth without resorting to SameSite=None.
func Set(w http.ResponseWriter, token string, expires time.Time, opts CookieOptions) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
Domain: opts.Domain,
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
Secure: opts.Secure,
SameSite: http.SameSiteLaxMode,
})
}
// Clear overwrites the auth cookie with an immediately-expired one.
func Clear(w http.ResponseWriter, opts CookieOptions) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
Domain: opts.Domain,
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: opts.Secure,
SameSite: http.SameSiteLaxMode,
})
}
// Read pulls the raw JWT out of the request cookie. Returns "" if missing.
func Read(r *http.Request) string {
c, err := r.Cookie(CookieName)
if err != nil {
return ""
}
return c.Value
}
+83
View File
@@ -0,0 +1,83 @@
// Package auth provides JWT issuance + verification for the BFF.
//
// Tokens carry these claims:
//
// sub user id (UUID string)
// tv token_version snapshot — middleware rejects if it doesn't match the
// current value on the user row. Bumping the user's token_version
// invalidates every JWT ever issued to that user (used on password
// change and forced logout).
// iat issued-at
// exp expiry
package auth
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims is the JWT payload we sign + verify.
type Claims struct {
UserID string `json:"sub"`
TokenVersion int `json:"tv"`
jwt.RegisteredClaims
}
// Issuer issues and verifies tokens with a single shared HMAC secret.
type Issuer struct {
secret []byte
ttl time.Duration
}
// NewIssuer returns an Issuer configured with the given HMAC secret and TTL.
// The TTL is applied to every token issued via Issue().
func NewIssuer(secret string, ttl time.Duration) *Issuer {
return &Issuer{secret: []byte(secret), ttl: ttl}
}
// Issue mints a signed JWT for the given user.
func (i *Issuer) Issue(userID string, tokenVersion int) (string, time.Time, error) {
exp := time.Now().Add(i.ttl)
claims := &Claims{
UserID: userID,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(exp),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := tok.SignedString(i.secret)
if err != nil {
return "", time.Time{}, fmt.Errorf("sign jwt: %w", err)
}
return signed, exp, nil
}
// ErrInvalidToken is returned by Parse for any verification failure.
var ErrInvalidToken = errors.New("invalid or expired token")
// Parse validates the token's signature + expiry and returns the claims.
// Callers must additionally compare claims.TokenVersion against the user's
// current token_version (the JWT alone can't tell us whether the user has
// since bumped their version).
func (i *Issuer) Parse(raw string) (*Claims, error) {
claims := &Claims{}
tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return i.secret, nil
})
if err != nil || !tok.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
// TTL returns the configured token lifetime. Handlers use it to set cookie
// Max-Age so the cookie and the JWT expire in lockstep.
func (i *Issuer) TTL() time.Duration { return i.ttl }
+103
View File
@@ -0,0 +1,103 @@
// Package mailer is a thin SMTP wrapper used by the auth flows.
//
// The stdlib `net/smtp` is sufficient for our needs (password reset +
// email verification — low volume, plain-MIME). We keep one interface
// (Mailer) so handlers can be tested with an in-memory implementation
// and so we can swap to a transactional provider later (Resend, SES)
// without touching the call sites.
package mailer
import (
"fmt"
"net/smtp"
"strings"
"go.uber.org/zap"
)
// Mailer is the small surface auth services depend on.
type Mailer interface {
Send(to, subject, htmlBody string) error
}
// Config carries the per-environment SMTP knobs.
type Config struct {
Host string
Port int
Username string
Password string
From string // "Name <addr@host>" or just "addr@host"
}
// Configured returns true if enough SMTP creds are present to send mail.
// Used by the auth service to decide whether to send-or-log.
func (c Config) Configured() bool { return c.Host != "" && c.From != "" }
// SMTP is the production Mailer. Connects per call (low volume → fine).
type SMTP struct {
cfg Config
logger *zap.Logger
}
// New returns an SMTP mailer if creds are present, otherwise a NoopLogger
// that logs the message body to zap (useful in dev — the password-reset
// link shows up in your server logs).
func New(cfg Config, logger *zap.Logger) Mailer {
if !cfg.Configured() {
logger.Warn("SMTP not configured — mailer will log messages instead of sending")
return &NoopLogger{logger: logger}
}
return &SMTP{cfg: cfg, logger: logger}
}
// Send delivers a single HTML email. Plain-text alternative is omitted —
// the templates are short and modern clients render HTML fine.
func (m *SMTP) Send(to, subject, htmlBody string) error {
addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port)
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
var msg strings.Builder
msg.WriteString("From: ")
msg.WriteString(m.cfg.From)
msg.WriteString("\r\n")
msg.WriteString("To: ")
msg.WriteString(to)
msg.WriteString("\r\n")
msg.WriteString("Subject: ")
msg.WriteString(subject)
msg.WriteString("\r\n")
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
msg.WriteString(htmlBody)
// Most relays on port 587 want STARTTLS + AUTH. Port 465 = implicit TLS,
// not handled here — add only if needed.
if err := smtp.SendMail(addr, auth, fromAddr(m.cfg.From), []string{to}, []byte(msg.String())); err != nil {
return fmt.Errorf("smtp send: %w", err)
}
return nil
}
// fromAddr extracts the bare address from a "Name <addr>" string. SendMail's
// `from` argument is the envelope-from, which must be the bare address.
func fromAddr(s string) string {
if i := strings.LastIndex(s, "<"); i >= 0 {
if j := strings.LastIndex(s, ">"); j > i {
return s[i+1 : j]
}
}
return s
}
// NoopLogger satisfies Mailer by writing the message into the application
// logger. Use it in development so you can copy reset links from the console.
type NoopLogger struct{ logger *zap.Logger }
func (n *NoopLogger) Send(to, subject, htmlBody string) error {
n.logger.Info("📧 mailer (not sent — SMTP unconfigured)",
zap.String("to", to),
zap.String("subject", subject),
zap.String("body", htmlBody))
return nil
}