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