1104 lines
43 KiB
Go
1104 lines
43 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chiMiddleware "github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
"github.com/prabhupadasays/spsays-bff/internal/config"
|
|
"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/oauth"
|
|
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
|
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
|
|
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Server holds all dependencies for the HTTP server.
|
|
type Server struct {
|
|
cfg *config.Config
|
|
logger *zap.Logger
|
|
router chi.Router
|
|
mongo *repository.MongoDB
|
|
pg *repository.Postgres
|
|
redis *repository.Redis
|
|
|
|
// discipleRepo is held on the struct (unlike the other Mongo repos, which
|
|
// stay local to New) because initAuth re-builds discipleH with the
|
|
// Postgres userRepo once it exists — see the direct-disciple link feature.
|
|
discipleRepo *repository.DiscipleRepo
|
|
|
|
// Worker lifecycle
|
|
workerCancel context.CancelFunc
|
|
workerCtx context.Context // derived in initShortener; cancelled on Close
|
|
|
|
// Handlers
|
|
bookH *handler.BookHandler
|
|
letterH *handler.LetterHandler
|
|
essayH *handler.EssayHandler
|
|
glossaryH *handler.GlossaryHandler
|
|
discipleH *handler.DiscipleHandler
|
|
journalH *handler.JournalHandler
|
|
magazineH *handler.MagazineHandler
|
|
otherDocH *handler.OtherDocumentHandler
|
|
transcriptionH *handler.TranscriptionHandler
|
|
searchH *handler.SearchHandler
|
|
vaniH *handler.VaniHandler
|
|
sitemapH *handler.SitemapHandler
|
|
shortURLH *handler.ShortURLHandler
|
|
authH *handler.AuthHandler
|
|
oauthH *handler.OAuthHandler
|
|
userH *handler.UserHandler
|
|
audioH *handler.AudioProgressHandler
|
|
postH *handler.PostHandler
|
|
commentH *handler.CommentHandler
|
|
bookmarkH *handler.BookmarkHandler
|
|
collectionH *handler.CollectionHandler
|
|
profileH *handler.ProfileHandler
|
|
relationH *handler.RelationshipHandler
|
|
soketiWebhookH *handler.SoketiWebhookHandler
|
|
broadcastAuthH *handler.BroadcastingAuthHandler
|
|
notificationH *handler.NotificationHandler
|
|
sadhnaH *handler.SadhnaHandler
|
|
pushH *handler.PushHandler
|
|
reminderH *handler.ReminderHandler
|
|
feedbackH *handler.FeedbackHandler
|
|
statsH *handler.StatsHandler
|
|
adminH *handler.AdminHandler
|
|
|
|
// 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.
|
|
func New(cfg *config.Config, logger *zap.Logger) *Server {
|
|
mongoDB, err := repository.NewMongoDB(cfg.MongoURI, cfg.MongoDB, logger)
|
|
if err != nil {
|
|
logger.Fatal("failed to connect to MongoDB", zap.Error(err))
|
|
}
|
|
|
|
// Initialize Typesense client
|
|
tsClient := repository.NewTypesenseClient(cfg.TypesenseURL, cfg.TypesenseAPIKey)
|
|
|
|
// Initialize repositories
|
|
bookRepo := repository.NewBookRepo(mongoDB)
|
|
letterRepo := repository.NewLetterRepo(mongoDB)
|
|
essayRepo := repository.NewEssayRepo(mongoDB)
|
|
glossaryRepo := repository.NewGlossaryRepo(mongoDB)
|
|
discipleRepo := repository.NewDiscipleRepo(mongoDB)
|
|
journalRepo := repository.NewJournalRepo(mongoDB)
|
|
magazineRepo := repository.NewMagazineRepo(mongoDB)
|
|
otherDocRepo := repository.NewOtherDocumentRepo(mongoDB)
|
|
transcriptionRepo := repository.NewTranscriptionRepo(mongoDB)
|
|
sitemapRepo := repository.NewSitemapRepo(mongoDB)
|
|
|
|
s := &Server{
|
|
cfg: cfg,
|
|
logger: logger,
|
|
mongo: mongoDB,
|
|
|
|
discipleRepo: discipleRepo,
|
|
|
|
// Initialize handlers
|
|
bookH: handler.NewBookHandler(bookRepo),
|
|
letterH: handler.NewLetterHandler(letterRepo),
|
|
essayH: handler.NewEssayHandler(essayRepo),
|
|
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
|
|
// userRepo half is attached later in initAuth (Postgres isn't up yet).
|
|
discipleH: handler.NewDiscipleHandler(discipleRepo, nil),
|
|
journalH: handler.NewJournalHandler(journalRepo),
|
|
magazineH: handler.NewMagazineHandler(magazineRepo),
|
|
otherDocH: handler.NewOtherDocumentHandler(otherDocRepo),
|
|
transcriptionH: handler.NewTranscriptionHandler(transcriptionRepo),
|
|
searchH: handler.NewSearchHandler(tsClient),
|
|
sitemapH: handler.NewSitemapHandler(sitemapRepo),
|
|
}
|
|
|
|
// URL shortener: Postgres (canonical) + Redis (queue + hot cache) + background workers.
|
|
// 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)
|
|
oauthRepo := repository.NewOAuthAccountRepo(s.pg)
|
|
|
|
r2, err := repository.NewR2(repository.R2Config{
|
|
AccountID: cfg.R2AccountID,
|
|
AccessKeyID: cfg.R2AccessKeyID,
|
|
SecretAccessKey: cfg.R2SecretAccessKey,
|
|
Bucket: cfg.R2Bucket,
|
|
PublicURL: cfg.R2PublicURL,
|
|
PrivateBucket: cfg.R2PrivateBucket,
|
|
})
|
|
if err != nil {
|
|
logger.Warn("auth: R2 unavailable — avatar uploads will return 503", zap.Error(err))
|
|
}
|
|
if r2 == nil {
|
|
logger.Info("auth: R2 not configured — avatar presign endpoint will return 503")
|
|
}
|
|
|
|
issuer := auth.NewIssuer(cfg.JWTSecret, cfg.JWTExpiration)
|
|
mail := mailer.New(mailer.Config{
|
|
Host: cfg.SMTPHost,
|
|
Port: cfg.SMTPPort,
|
|
Username: cfg.SMTPUsername,
|
|
Password: cfg.SMTPPassword,
|
|
From: cfg.SMTPFrom,
|
|
}, logger)
|
|
|
|
authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL)
|
|
authSvc.SetRevalidator(revalidate.New(cfg.FrontendURL, cfg.RevalidateSecret, logger))
|
|
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)
|
|
// Likes: membership in Postgres, count delta batched through Redis
|
|
// (s.redis may be nil — LikeRepo then applies counts directly).
|
|
likeRepo := repository.NewLikeRepo(s.pg, s.redis, logger)
|
|
mentionRepo := repository.NewMentionRepo(s.pg)
|
|
commentRepo := repository.NewCommentRepo(s.pg, logger)
|
|
// Per-user "already served" set so the home feed never repeats posts
|
|
// across pages (nil-safe — falls back to offset paging without Redis).
|
|
feedRepo := repository.NewFeedRepo(s.redis, logger)
|
|
postSvc := service.NewPostService(postRepo, r2, likeRepo, mentionRepo, commentRepo, feedRepo)
|
|
// Soketi signs webhooks with the app secret; on channel_vacated for a
|
|
// per-user notifications channel we evict that user's feed seen-set.
|
|
s.soketiWebhookH = handler.NewSoketiWebhookHandler(cfg.SoketiAppKey, cfg.SoketiAppSecret, feedRepo, logger)
|
|
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)
|
|
|
|
// Re-wire the disciple handler now that Postgres-backed users exist: its
|
|
// /disciples/linked route joins the Mongo disciple records to the platform
|
|
// accounts an admin has linked to them. Built in New() with a nil userRepo
|
|
// (Mongo is up before Postgres), so this is where it gets the other half.
|
|
if s.discipleRepo != nil {
|
|
s.discipleH = handler.NewDiscipleHandler(s.discipleRepo, userRepo)
|
|
}
|
|
|
|
// Audio playback resume points — per-user, per-file last position that
|
|
// lets the floating player pick up where it left off across devices.
|
|
audioProgressRepo := repository.NewAudioProgressRepo(s.pg)
|
|
s.audioH = handler.NewAudioProgressHandler(service.NewAudioProgressService(audioProgressRepo))
|
|
|
|
// Vāṇī of the Day — one daily pick (prose/verse/transcript) for the home
|
|
// page, chosen from Mongo content and recorded in Postgres so it doesn't
|
|
// repeat within 90 days.
|
|
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
|
|
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
|
|
|
|
// OAuth — wired only when at least one provider is configured. The
|
|
// registry skips providers with missing creds, so partial configs (e.g.
|
|
// Google only, no Facebook) work out of the box.
|
|
providers := oauth.NewRegistry(oauth.Credentials{
|
|
GoogleClientID: cfg.GoogleOAuthClientID,
|
|
GoogleClientSecret: cfg.GoogleOAuthClientSecret,
|
|
FacebookClientID: cfg.FacebookOAuthClientID,
|
|
FacebookClientSecret: cfg.FacebookOAuthClientSecret,
|
|
})
|
|
if len(providers) > 0 {
|
|
s.oauthH = handler.NewOAuthHandler(
|
|
authSvc, cookieOpts, providers,
|
|
[]byte(cfg.JWTSecret), cfg.BFFPublicURL, cfg.FrontendURL,
|
|
logger,
|
|
)
|
|
} else {
|
|
logger.Info("oauth: no providers configured — /auth/oauth/* will 404")
|
|
}
|
|
s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc)
|
|
s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo)
|
|
|
|
// Social graph: follows / blocks / reports, plus the notification
|
|
// "plugin" seam (persists rows + emails today; websocket/push later).
|
|
// All share the same Postgres pool + mailer. Reports reuse the
|
|
// feedback admin recipients.
|
|
followRepo := repository.NewFollowRepo(s.pg)
|
|
blockRepo := repository.NewBlockRepo(s.pg)
|
|
reportRepo := repository.NewReportRepo(s.pg)
|
|
notifRepo := repository.NewNotificationRepo(s.pg)
|
|
rtClient := realtime.NewSoketiClient(
|
|
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
|
|
cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger,
|
|
)
|
|
// Channel authorization for private-* subscriptions. Rules are explicit
|
|
// and default-deny; chat conversation channels get added here.
|
|
s.broadcastAuthH = handler.NewBroadcastingAuthHandler(rtClient, logger,
|
|
handler.NotificationsRule(),
|
|
)
|
|
|
|
pushRepo := repository.NewPushRepo(s.pg)
|
|
pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger)
|
|
s.pushH = handler.NewPushHandler(pushSender, pushRepo)
|
|
notifPrefsRepo := repository.NewNotificationPrefsRepo(s.pg)
|
|
notifSvc := service.NewNotificationService(notifRepo, notifPrefsRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL)
|
|
// Wire the notification seam into the post service so a new like notifies
|
|
// the author (push + realtime; not the liker themselves).
|
|
postSvc.SetNotifier(notifSvc)
|
|
|
|
// Comments: comment / reply / like / pin / report over the same Postgres
|
|
// pool, with the notification seam so new comments notify the post author
|
|
// and replies notify the comment they answer.
|
|
commentSvc := service.NewCommentService(commentRepo)
|
|
commentSvc.SetNotifier(notifSvc)
|
|
s.commentH = handler.NewCommentHandler(commentSvc)
|
|
|
|
// Sādhana reminders: a background worker nudges users (email + push +
|
|
// websocket) to chant / post a quote at their local time. Shares the
|
|
// notification seam above so all three channels fire.
|
|
reminderRepo := repository.NewReminderRepo(s.pg)
|
|
reminderSvc := service.NewReminderService(reminderRepo, notifPrefsRepo, userRepo, notifSvc, logger, cfg.FrontendURL)
|
|
s.reminderH = handler.NewReminderHandler(reminderSvc)
|
|
wctx := s.workerCtx
|
|
if wctx == nil {
|
|
wctx = context.Background()
|
|
}
|
|
worker.NewReminderWorker(reminderSvc, logger).Run(wctx)
|
|
|
|
// Likes worker: folds the pending Redis like-deltas into posts.likes_count
|
|
// every 15 minutes (no-op when Redis is unavailable).
|
|
worker.NewLikesWorker(likeRepo, logger).Run(wctx)
|
|
|
|
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
|
|
s.relationH = handler.NewRelationshipHandler(graphSvc)
|
|
s.notificationH = handler.NewNotificationHandler(notifSvc)
|
|
s.postH = handler.NewPostHandler(postSvc, graphSvc)
|
|
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc, likeRepo, mentionRepo, commentRepo)
|
|
|
|
// Sādhana streak: daily chanting log + the growing-plant state. Reads
|
|
// the chanting half from sadhna_logs and the posting half from the
|
|
// existing posts table, so it only needs its own repo plus PostRepo +
|
|
// UserRepo (for the rounds target).
|
|
sadhnaRepo := repository.NewSadhnaRepo(s.pg)
|
|
rewardRepo := repository.NewRewardRepo(s.pg)
|
|
// Sync the reward catalog from the embedded rewards.json (idempotent).
|
|
if err := rewardRepo.Seed(context.Background()); err != nil {
|
|
logger.Warn("sadhna: could not seed reward catalog", zap.Error(err))
|
|
}
|
|
sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, logger)
|
|
s.sadhnaH = handler.NewSadhnaHandler(sadhnaSvc)
|
|
|
|
// Feedback + stats — share Postgres with the rest of /api/v1 and
|
|
// the existing mailer instance.
|
|
feedbackRepo := repository.NewFeedbackRepo(s.pg)
|
|
statsRepo := repository.NewStatsRepo(s.pg)
|
|
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
|
|
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
|
|
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo)
|
|
|
|
// Admin panel — read-mostly listing + notify/reply/resolve actions.
|
|
// Reuses the notification service (delivery) and mailer (feedback reply).
|
|
// Gated by RequireAuth + RequireAdmin in the router.
|
|
adminRepo := repository.NewAdminRepo(s.pg)
|
|
adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL)
|
|
s.adminH = handler.NewAdminHandler(adminSvc)
|
|
}
|
|
|
|
// 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.
|
|
func (s *Server) initShortener(cfg *config.Config, logger *zap.Logger) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.workerCancel = cancel
|
|
s.workerCtx = ctx
|
|
|
|
pg, err := repository.NewPostgres(ctx, cfg.PostgresURI, logger)
|
|
if err != nil {
|
|
logger.Warn("URL shortener: Postgres unavailable — /share endpoints will return 503",
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
s.pg = pg
|
|
|
|
migrationsDir, err := filepath.Abs("migrations")
|
|
if err == nil {
|
|
if err := pg.RunMigrations(ctx, migrationsDir); err != nil {
|
|
logger.Warn("URL shortener: migrations failed", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
rd, err := repository.NewRedis(ctx, cfg.RedisURI, logger)
|
|
if err != nil {
|
|
logger.Warn("URL shortener: Redis unavailable — /share endpoints will return 503",
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
s.redis = rd
|
|
|
|
shortRepo := repository.NewShortURLRepo(pg)
|
|
queue := shorturl.NewQueue(rd.Client)
|
|
s.shortURLH = handler.NewShortURLHandler(shortRepo, queue, logger)
|
|
|
|
w := worker.NewShortURLWorker(shortRepo, queue, logger)
|
|
w.Run(ctx)
|
|
}
|
|
|
|
// Router returns the chi router.
|
|
func (s *Server) Router() http.Handler {
|
|
return s.router
|
|
}
|
|
|
|
// Close shuts down all connections gracefully.
|
|
func (s *Server) Close() {
|
|
if s.workerCancel != nil {
|
|
s.workerCancel()
|
|
}
|
|
if err := s.mongo.Close(); err != nil {
|
|
s.logger.Error("failed to close MongoDB connection", zap.Error(err))
|
|
}
|
|
if s.pg != nil {
|
|
s.pg.Close()
|
|
}
|
|
if s.redis != nil {
|
|
if err := s.redis.Close(); err != nil {
|
|
s.logger.Error("failed to close Redis connection", zap.Error(err))
|
|
}
|
|
}
|
|
s.logger.Info("all connections closed")
|
|
}
|
|
|
|
// sitemapUsers — GET /api/v1/sitemap/users → { "urls": ["/u/handle", …] }.
|
|
// Public-profile sitemap. Reads userRepo at request time (it's wired only
|
|
// after Postgres init); degrades to an empty list when Postgres is down.
|
|
func (s *Server) sitemapUsers(w http.ResponseWriter, r *http.Request) {
|
|
if s.userRepo == nil {
|
|
response.JSON(w, http.StatusOK, map[string]any{"urls": []string{}})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
handles, err := s.userRepo.AllHandles(ctx)
|
|
if err != nil {
|
|
response.Error(w, http.StatusInternalServerError, "could not build user sitemap")
|
|
return
|
|
}
|
|
urls := make([]string, 0, len(handles))
|
|
for _, h := range handles {
|
|
urls = append(urls, "/u/"+h)
|
|
}
|
|
response.JSON(w, http.StatusOK, map[string]any{"urls": urls})
|
|
}
|
|
|
|
// 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.
|
|
func injectBookCode(code string) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rctx := chi.RouteContext(r.Context())
|
|
rctx.URLParams.Add("code", code)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *Server) setupRouter() {
|
|
r := chi.NewRouter()
|
|
|
|
// Global middleware
|
|
r.Use(chiMiddleware.RequestID)
|
|
// Our RealIP (not chi's): a single trusted edge proxy (Dokploy's Traefik)
|
|
// appends the real client to the right of X-Forwarded-For, so we read the
|
|
// right-most entry. chi's RealIP trusts the spoofable left-most entry.
|
|
r.Use(middleware.RealIP)
|
|
r.Use(middleware.Logger(s.logger))
|
|
r.Use(chiMiddleware.Recoverer)
|
|
r.Use(chiMiddleware.Compress(5))
|
|
|
|
// CORS — the main frontend plus, when configured, the admin panel (a
|
|
// separate origin). Empty origins are filtered so the list never widens
|
|
// unintentionally.
|
|
allowedOrigins := []string{}
|
|
for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} {
|
|
if o != "" {
|
|
allowedOrigins = append(allowedOrigins, o)
|
|
}
|
|
}
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: allowedOrigins,
|
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
|
|
ExposedHeaders: []string{"X-Request-ID"},
|
|
AllowCredentials: true,
|
|
MaxAge: 300,
|
|
}))
|
|
|
|
// Health check
|
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
|
|
// Public ops snapshot — aggregate counts + recent feedback rendered
|
|
// as HTML. Lives outside /api/v1 because it returns HTML, not JSON.
|
|
// Currently unauthenticated by product decision; gate here if that
|
|
// ever needs to change.
|
|
if s.statsH != nil {
|
|
// Read-only public snapshot. The feedback acknowledge/resolve actions
|
|
// moved to the admin panel (POST /api/v1/admin/feedback/{id}/...) — they
|
|
// must not be exposed on this unauthenticated page.
|
|
r.Get("/stats", s.statsH.Render)
|
|
}
|
|
|
|
// API v1
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
// Sitemap — flat list of public content URLs (consumed by the
|
|
// frontend's daily-cached sitemap routes). Public, read-only.
|
|
r.Get("/sitemap/content", s.sitemapH.GetContent)
|
|
// Public user profiles (Postgres) — separate from Mongo content.
|
|
r.Get("/sitemap/users", s.sitemapUsers)
|
|
|
|
// Books
|
|
r.Route("/books", func(r chi.Router) {
|
|
r.Get("/", s.bookH.ListBooks)
|
|
|
|
// --- Verse books with divisions ---
|
|
|
|
// SB — canto divisions (plus prose front-matter segments;
|
|
// the static /chapters routes take precedence over /{division}).
|
|
r.Route("/sb", func(r chi.Router) {
|
|
r.Use(injectBookCode("sb"))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/chapters", s.bookH.ListProseChapters)
|
|
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
|
|
r.Get("/{division}", s.bookH.GetDivisionInfo)
|
|
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
|
|
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
|
|
r.Get("/{division}/{chapter}/{verse}", s.bookH.GetDivisionVerse)
|
|
})
|
|
|
|
// CC — līlā divisions (plus prose front-matter segments;
|
|
// the static /chapters routes take precedence over /{division}).
|
|
r.Route("/cc", func(r chi.Router) {
|
|
r.Use(injectBookCode("cc"))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/chapters", s.bookH.ListProseChapters)
|
|
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
|
|
r.Get("/{division}", s.bookH.GetDivisionInfo)
|
|
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
|
|
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
|
|
r.Get("/{division}/{chapter}/{verse}", s.bookH.GetDivisionVerse)
|
|
})
|
|
|
|
// --- Verse books without divisions ---
|
|
// SB1962: pure verse book
|
|
for _, code := range []string{"sb1962"} {
|
|
code := code // capture for closure
|
|
r.Route("/"+code, func(r chi.Router) {
|
|
r.Use(injectBookCode(code))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/{chapter}", s.bookH.GetChapterVerseIndex)
|
|
r.Get("/{chapter}/verses", s.bookH.GetChapterVerses)
|
|
r.Get("/{chapter}/{verse}", s.bookH.GetVerse)
|
|
})
|
|
}
|
|
|
|
// --- Verse + prose intro books (no division) ---
|
|
// BG, BS, MM, NBS: verses plus prose front-matter segments
|
|
// (the static /chapters routes take precedence over /{chapter}).
|
|
for _, code := range []string{"bg", "bs", "mm", "nbs"} {
|
|
code := code
|
|
r.Route("/"+code, func(r chi.Router) {
|
|
r.Use(injectBookCode(code))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/chapters", s.bookH.ListProseChapters)
|
|
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
|
|
r.Get("/{chapter}", s.bookH.GetChapterVerseIndex)
|
|
r.Get("/{chapter}/verses", s.bookH.GetChapterVerses)
|
|
r.Get("/{chapter}/{verse}", s.bookH.GetVerse)
|
|
})
|
|
}
|
|
|
|
// --- Verse + prose intro books with special slugs ---
|
|
// ISO, NOI: mantras/texts accessed by slug, plus prose intros
|
|
for _, code := range []string{"iso", "noi"} {
|
|
code := code
|
|
r.Route("/"+code, func(r chi.Router) {
|
|
r.Use(injectBookCode(code))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/chapters", s.bookH.ListProseChapters)
|
|
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
|
|
r.Get("/{chapter}", s.bookH.GetChapterVerseIndex)
|
|
r.Get("/{chapter}/verses", s.bookH.GetChapterVerses)
|
|
r.Get("/{chapter}/{verse}", s.bookH.GetVerse)
|
|
})
|
|
}
|
|
|
|
// --- Volume-based prose book ---
|
|
// KB: 2 volumes, chapters within each volume
|
|
r.Route("/kb", func(r chi.Router) {
|
|
r.Use(injectBookCode("kb"))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/{volume}", s.bookH.ListVolumeChapters)
|
|
r.Get("/{volume}/{slug}", s.bookH.GetVolumeChapter)
|
|
})
|
|
|
|
// --- Simple prose books ---
|
|
// BBD, BK, CBHC, DS, EJ, EK, GG, HKH, LCL, LOB, MG, MOG, NOD,
|
|
// OCHKM, OWK, PK, POY, PQPA, RV, SSR, SVA, TLC, TLK, TQK, TTP, VA
|
|
for _, code := range []string{
|
|
"bbd", "bk", "cbhc", "ds", "ej", "ek", "gg", "hkh",
|
|
"lcl", "lob", "mg", "mog", "nod", "ochkm", "owk", "pk",
|
|
"poy", "pqpa", "rv", "ssr", "sva", "tlc", "tlk", "tqk",
|
|
"ttp", "va",
|
|
} {
|
|
code := code
|
|
r.Route("/"+code, func(r chi.Router) {
|
|
r.Use(injectBookCode(code))
|
|
r.Get("/", s.bookH.GetBook)
|
|
r.Get("/{slug}", s.bookH.GetProseChapter)
|
|
r.Get("/chapters", s.bookH.ListProseChapters)
|
|
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
|
|
})
|
|
}
|
|
})
|
|
|
|
// Letters
|
|
r.Route("/letters", func(r chi.Router) {
|
|
r.Get("/", s.letterH.Count)
|
|
r.Get("/years", s.letterH.GetYears)
|
|
r.Get("/recipients", s.letterH.GetRecipients)
|
|
r.Get("/locations", s.letterH.GetLocations)
|
|
r.Get("/year/{year}", s.letterH.GetByYear)
|
|
r.Get("/recipient/{name}", s.letterH.GetByRecipient)
|
|
r.Get("/location/{name}", s.letterH.GetByLocation)
|
|
r.Get("/{slug}", s.letterH.GetBySlug)
|
|
})
|
|
|
|
// Essays
|
|
r.Route("/essays", func(r chi.Router) {
|
|
r.Get("/", s.essayH.List)
|
|
r.Get("/{slug}", s.essayH.GetBySlug)
|
|
})
|
|
|
|
// Glossary
|
|
r.Route("/glossary", func(r chi.Router) {
|
|
r.Get("/", s.glossaryH.List)
|
|
r.Get("/letters", s.glossaryH.GetLetterCounts)
|
|
r.Get("/letter/{letter}", s.glossaryH.GetByLetter)
|
|
r.Get("/letter/{letter}/terms", s.glossaryH.GetTermsByLetter)
|
|
r.Get("/term/{term}", s.glossaryH.GetByTerm)
|
|
})
|
|
|
|
// Disciples
|
|
r.Route("/disciples", func(r chi.Router) {
|
|
r.Get("/", s.discipleH.List)
|
|
r.Get("/count", s.discipleH.Count)
|
|
r.Get("/letters", s.discipleH.LetterCounts)
|
|
r.Get("/suffixes", s.discipleH.SuffixCounts)
|
|
r.Get("/search", s.discipleH.Search)
|
|
// Disciples with a platform account (admin-linked) — drives the
|
|
// highlight in the /disciples browser.
|
|
r.Get("/linked", s.discipleH.Linked)
|
|
})
|
|
|
|
// Journals
|
|
r.Route("/journals", func(r chi.Router) {
|
|
r.Get("/", s.journalH.ListJournals)
|
|
r.Get("/{book}", s.journalH.GetJournal)
|
|
r.Get("/{book}/chapters", s.journalH.ListChapters)
|
|
r.Get("/{book}/chapters/{slug}", s.journalH.GetChapter)
|
|
})
|
|
|
|
// Magazines
|
|
r.Route("/magazines", func(r chi.Router) {
|
|
r.Get("/", s.magazineH.ListMagazines)
|
|
r.Get("/{book}", s.magazineH.GetMagazine)
|
|
r.Get("/{book}/issues", s.magazineH.ListIssues)
|
|
r.Get("/{book}/issues/{slug}", s.magazineH.GetIssue)
|
|
})
|
|
|
|
// Other Documents
|
|
r.Route("/other-documents", func(r chi.Router) {
|
|
r.Get("/", s.otherDocH.ListDocuments)
|
|
r.Get("/{book}", s.otherDocH.GetDocument)
|
|
r.Get("/{book}/chapters", s.otherDocH.ListChapters)
|
|
r.Get("/{book}/chapters/{slug}", s.otherDocH.GetChapter)
|
|
})
|
|
|
|
// Transcriptions
|
|
r.Route("/transcriptions", func(r chi.Router) {
|
|
r.Get("/", s.transcriptionH.Count)
|
|
r.Get("/years", s.transcriptionH.GetYears)
|
|
r.Get("/places", s.transcriptionH.GetPlaces)
|
|
r.Get("/types", s.transcriptionH.GetTypeDistribution)
|
|
r.Get("/books", s.transcriptionH.GetBooks)
|
|
r.Get("/year/{year}", s.transcriptionH.GetByYear)
|
|
r.Get("/place/{place}", s.transcriptionH.GetByPlace)
|
|
r.Get("/type/{type}", s.transcriptionH.GetByType)
|
|
r.Get("/book/{book}", s.transcriptionH.GetByBook)
|
|
r.Get("/adjacent/{slug}", s.transcriptionH.GetAdjacent)
|
|
r.Get("/{slug}", s.transcriptionH.GetBySlug)
|
|
})
|
|
|
|
// Search (Typesense)
|
|
r.Get("/search", s.searchH.Search)
|
|
|
|
// Vāṇī of the Day — public daily content pick for the home page.
|
|
r.Get("/vani/today", s.vaniH.Today)
|
|
|
|
// --- 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) {
|
|
if s.authH == nil {
|
|
r.HandleFunc("/*", s.authUnavailable)
|
|
return
|
|
}
|
|
r.Post("/signup", s.authH.SignUp)
|
|
r.Post("/login", s.authH.LogIn)
|
|
r.Post("/verify-login-otp", s.authH.VerifyLoginOTP)
|
|
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)
|
|
|
|
// OAuth (Google, Facebook, …). GET endpoints because the browser
|
|
// navigates directly to /start (top-level redirect) and the
|
|
// provider redirects back to /callback. Both flows set cookies
|
|
// and 302 — they never return JSON.
|
|
if s.oauthH != nil {
|
|
r.Get("/oauth/{provider}/start", s.oauthH.Start)
|
|
r.Get("/oauth/{provider}/callback", s.oauthH.Callback)
|
|
}
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Get("/me", s.authH.Me)
|
|
r.Get("/linked-providers", s.authH.LinkedProviders)
|
|
r.Post("/email", s.authH.SetEmail)
|
|
r.Post("/change-password", s.authH.ChangePassword)
|
|
r.Post("/resend-verification", s.authH.ResendVerification)
|
|
// Link-provider flow — requires the caller to be signed
|
|
// in; the state encodes their id so the callback links to
|
|
// THIS user instead of running the normal sign-in path.
|
|
// The callback itself is the shared /oauth/{provider}/callback
|
|
// above — the state's link_user_id field switches behaviour.
|
|
if s.oauthH != nil {
|
|
r.Get("/oauth/{provider}/link/start", s.oauthH.LinkStart)
|
|
}
|
|
})
|
|
})
|
|
|
|
// User profile — mixed public-read + protected writes.
|
|
r.Route("/users", func(r chi.Router) {
|
|
if s.userH == nil {
|
|
r.HandleFunc("/*", s.authUnavailable)
|
|
return
|
|
}
|
|
// Public read — search by handle / name. Returns a
|
|
// sanitized payload (no email / role / plan / etc.).
|
|
r.Get("/search", s.userH.Search)
|
|
// Protected writes — profile edit, avatar presign,
|
|
// reading-prefs roaming.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Patch("/me", s.userH.UpdateProfile)
|
|
r.Post("/me/avatar-presign", s.userH.AvatarPresign)
|
|
// Reading prefs roam with the account so theme/palette/
|
|
// font picks follow the user across devices. Body shape
|
|
// is owned by the frontend; the server treats it as an
|
|
// opaque JSON object.
|
|
r.Get("/me/reading-prefs", s.userH.GetReadingPrefs)
|
|
r.Put("/me/reading-prefs", s.userH.UpdateReadingPrefs)
|
|
// Audio player resume points roam with the account too, so a
|
|
// lecture picks up where you left off on any device. Schema is
|
|
// owned by the frontend; the server stores opaque key/position
|
|
// rows and merges newest-wins.
|
|
r.Get("/me/audio-progress", s.audioH.Get)
|
|
r.Put("/me/audio-progress", s.audioH.Put)
|
|
r.Delete("/me/audio-progress", s.audioH.Delete)
|
|
})
|
|
})
|
|
|
|
// --- Social: posts ---
|
|
r.Route("/posts", func(r chi.Router) {
|
|
if s.postH == nil {
|
|
r.HandleFunc("/*", s.authUnavailable)
|
|
return
|
|
}
|
|
// Public read — anonymous-OK but populates viewer_id when a
|
|
// session cookie is present, so the response can include
|
|
// per-viewer fields like `viewer_has_reshared`.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
|
r.Get("/{id}", s.postH.Get)
|
|
// Paginated "liked by" list — public, like the count itself.
|
|
r.Get("/{id}/likes", s.postH.ListLikes)
|
|
// Tagged users on a post — public (read-only for non-authors).
|
|
r.Get("/{id}/mentions", s.postH.ListMentions)
|
|
// Comments — public read (viewer_has_liked populated when signed in).
|
|
if s.commentH != nil {
|
|
r.Get("/{id}/comments", s.commentH.List)
|
|
}
|
|
})
|
|
// Protected — create / delete / image presign / "my posts" /
|
|
// reshare actions.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Post("/", s.postH.Create)
|
|
r.Delete("/{id}", s.postH.Delete)
|
|
// Hide / unhide from timelines + public collections (owner-only).
|
|
r.Post("/{id}/hide", s.postH.Hide)
|
|
r.Delete("/{id}/hide", s.postH.Unhide)
|
|
r.Put("/{id}/images", s.postH.ReplaceImages)
|
|
r.Get("/mine", s.postH.ListMine)
|
|
r.Get("/hidden", s.postH.ListHidden)
|
|
r.Post("/image-presign", s.postH.ImagePresign)
|
|
// Reshare is auth-required: anonymous users can't claim
|
|
// to have reshared anything. The action is idempotent so
|
|
// double-clicks don't 4xx.
|
|
r.Post("/{id}/reshare", s.postH.Reshare)
|
|
r.Delete("/{id}/reshare", s.postH.Unreshare)
|
|
// Like / unlike — idempotent; membership is written now, the
|
|
// count is batched into likes_count via Redis.
|
|
r.Post("/{id}/like", s.postH.Like)
|
|
r.Delete("/{id}/like", s.postH.Unlike)
|
|
// Mentions: composer suggestions + author add/remove of tags.
|
|
r.Get("/mention-suggest", s.postH.MentionSuggest)
|
|
r.Post("/{id}/mentions", s.postH.AddMention)
|
|
r.Delete("/{id}/mentions/{userID}", s.postH.RemoveMention)
|
|
// Report a post (category + optional reason) → admin email.
|
|
r.Post("/{id}/report", s.postH.Report)
|
|
// Add a comment / reply (anyone signed in).
|
|
if s.commentH != nil {
|
|
r.Post("/{id}/comments", s.commentH.Create)
|
|
}
|
|
})
|
|
})
|
|
|
|
// --- Social: comments (replies, likes, pin, delete, report) ---
|
|
// Keyed by comment id; the post-scoped list/create live under /posts.
|
|
r.Route("/comments", func(r chi.Router) {
|
|
if s.commentH == nil {
|
|
r.HandleFunc("/*", s.authUnavailable)
|
|
return
|
|
}
|
|
// Public read — a thread's replies (viewer_has_liked when signed in).
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
|
r.Get("/{id}/replies", s.commentH.Replies)
|
|
})
|
|
// Protected — like / pin / delete / report.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Post("/{id}/like", s.commentH.Like)
|
|
r.Delete("/{id}/like", s.commentH.Unlike)
|
|
// Pin / unpin — post author only (enforced in the service).
|
|
r.Post("/{id}/pin", s.commentH.Pin)
|
|
r.Delete("/{id}/pin", s.commentH.Unpin)
|
|
// Delete — comment author or post author; removes the thread.
|
|
r.Delete("/{id}", s.commentH.Delete)
|
|
// Report a comment (category + optional reason).
|
|
r.Post("/{id}/report", s.commentH.Report)
|
|
})
|
|
})
|
|
|
|
// --- Social: bookmarks (all protected; bookmarks are private by design) ---
|
|
r.Route("/bookmarks", func(r chi.Router) {
|
|
if s.bookmarkH == nil {
|
|
r.HandleFunc("/*", s.authUnavailable)
|
|
return
|
|
}
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Post("/", s.bookmarkH.Create)
|
|
r.Get("/", s.bookmarkH.List)
|
|
r.Delete("/{id}", s.bookmarkH.Delete)
|
|
})
|
|
|
|
// --- Social: collections ---
|
|
// The single-collection GET is the only route that may be hit
|
|
// anonymously (public collections); everything else needs auth.
|
|
// OptionalAuth on the GET lets us populate `viewer_has_reshared`
|
|
// on each post in the collection's carousel when a viewer is
|
|
// signed in, without rejecting anonymous traffic.
|
|
r.Route("/collections", func(r chi.Router) {
|
|
if s.collectionH == nil {
|
|
r.HandleFunc("/*", s.authUnavailable)
|
|
return
|
|
}
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
|
r.Get("/{id}", s.collectionH.Get)
|
|
})
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Post("/", s.collectionH.Create)
|
|
r.Get("/", s.collectionH.ListMine)
|
|
r.Patch("/{id}", s.collectionH.Update)
|
|
r.Delete("/{id}", s.collectionH.Delete)
|
|
r.Post("/{id}/items", s.collectionH.AddItem)
|
|
r.Put("/{id}/items/order", s.collectionH.ReorderItems)
|
|
r.Delete("/{id}/items/{post_id}", s.collectionH.RemoveItem)
|
|
r.Get("/containing/{post_id}", s.collectionH.ContainingPost)
|
|
r.Post("/cover-presign", s.collectionH.CoverPresign)
|
|
})
|
|
})
|
|
|
|
// --- Public profile + feed ---
|
|
// OptionalAuth populates viewer_id when a session cookie is
|
|
// present, so the feed can carry `viewer_has_reshared` on each
|
|
// post. Without a cookie the routes still respond — they just
|
|
// return the anonymous shape (all-false).
|
|
// Soketi webhooks — server-to-server, authenticated by the HMAC
|
|
// signature (not a session). channel_vacated on a per-user
|
|
// notifications channel evicts that user's feed seen-set.
|
|
if s.soketiWebhookH != nil {
|
|
r.Post("/webhooks/soketi", s.soketiWebhookH.Handle)
|
|
}
|
|
|
|
// Pusher channel authorization — pusher-js posts here (as its
|
|
// `authEndpoint`) before subscribing to a private-/presence- channel.
|
|
// Session-authenticated: the signature is only minted for a caller
|
|
// who has proven who they are and passed the channel's rule.
|
|
if s.broadcastAuthH != nil {
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Post("/broadcasting/auth", s.broadcastAuthH.Handle)
|
|
})
|
|
}
|
|
|
|
// Home feed — the global, personalised feed of posts + its "new on
|
|
// the platform" discovery strip. Auth-required (the feed is
|
|
// per-viewer: ordering + relationship flags depend on the caller).
|
|
r.Route("/feed", func(r chi.Router) {
|
|
if s.postH == nil {
|
|
r.HandleFunc("/*", s.authUnavailable)
|
|
return
|
|
}
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Get("/", s.postH.Feed)
|
|
r.Get("/new-users", s.postH.FeedNewUsers)
|
|
// Clear the per-user seen-set (logout / page unload).
|
|
r.Post("/reset", s.postH.FeedReset)
|
|
})
|
|
})
|
|
|
|
// Caller's pending incoming follow requests (the notifications
|
|
// "Friend Requests" tab). Caller-scoped, so it lives outside /u/{handle}.
|
|
if s.profileH != nil {
|
|
r.With(middleware.RequireAuth(s.authIssuer, s.userRepo)).
|
|
Get("/follow-requests", s.profileH.IncomingFollowRequests)
|
|
}
|
|
|
|
r.Route("/u", func(r chi.Router) {
|
|
if s.profileH == nil {
|
|
r.HandleFunc("/*", s.authUnavailable)
|
|
return
|
|
}
|
|
// Public reads (anonymous-OK; OptionalAuth populates viewer_id
|
|
// so Get can include the viewer's relationship + the feed can
|
|
// carry viewer_has_reshared).
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
|
r.Get("/{handle}", s.profileH.Get)
|
|
r.Get("/{handle}/feed", s.profileH.Feed)
|
|
r.Get("/{handle}/mentions", s.profileH.MentionsFeed)
|
|
r.Get("/{handle}/followers", s.profileH.Followers)
|
|
r.Get("/{handle}/following", s.profileH.Following)
|
|
})
|
|
|
|
// Relationship actions — follow/block/report. All require a
|
|
// signed-in viewer; each returns the recomputed relationship
|
|
// + the target's follower/following counts. Follows are
|
|
// request-based: POST /follow creates a PENDING request that
|
|
// the target later accepts or rejects.
|
|
if s.relationH != nil {
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Post("/{handle}/follow", s.relationH.Follow)
|
|
r.Delete("/{handle}/follow", s.relationH.Unfollow)
|
|
r.Post("/{handle}/follow/accept", s.relationH.AcceptFollow)
|
|
r.Post("/{handle}/follow/reject", s.relationH.RejectFollow)
|
|
r.Delete("/{handle}/follower", s.relationH.RemoveFollower)
|
|
r.Post("/{handle}/block", s.relationH.Block)
|
|
r.Delete("/{handle}/block", s.relationH.Unblock)
|
|
r.Post("/{handle}/report", s.relationH.Report)
|
|
})
|
|
}
|
|
})
|
|
// URL Shortener — Postgres-backed, Redis-queued.
|
|
// The handler short-circuits to 503 if Postgres or Redis are unavailable.
|
|
r.Route("/share", func(r chi.Router) {
|
|
if s.shortURLH != nil {
|
|
r.Post("/", s.shortURLH.Create)
|
|
r.Get("/{code}", s.shortURLH.Resolve)
|
|
} else {
|
|
stub := func(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":"url shortener unavailable"}}`))
|
|
}
|
|
r.Post("/", stub)
|
|
r.Get("/{code}", stub)
|
|
}
|
|
})
|
|
|
|
// --- Feedback ---
|
|
// OptionalAuth so authed callers' user_id is captured
|
|
// automatically (handler reads middleware.UserID), and
|
|
// anonymous callers are still accepted (they supply
|
|
// contact_email instead).
|
|
r.Group(func(r chi.Router) {
|
|
if s.feedbackH == nil {
|
|
r.HandleFunc("/feedback", s.authUnavailable)
|
|
r.HandleFunc("/feedback/mine", s.authUnavailable)
|
|
return
|
|
}
|
|
// Submit — OptionalAuth (anonymous callers allowed).
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
|
r.Post("/feedback", s.feedbackH.Submit)
|
|
})
|
|
// My submissions — RequireAuth. Lists the caller's own rows
|
|
// with status + admin reply for the /settings/feedbacks page.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Get("/feedback/mine", s.feedbackH.Mine)
|
|
})
|
|
})
|
|
|
|
// Notifications — the signed-in user's in-app feed + unread badge.
|
|
// Rows are persisted on every follow/accept event; websocket + push
|
|
// delivery will layer on later. All protected.
|
|
r.Route("/notifications", func(r chi.Router) {
|
|
if s.notificationH == nil {
|
|
r.HandleFunc("/*", s.authUnavailable)
|
|
return
|
|
}
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Get("/", s.notificationH.List)
|
|
r.Get("/unread-count", s.notificationH.UnreadCount)
|
|
r.Post("/read", s.notificationH.MarkAllRead)
|
|
r.Get("/prefs", s.notificationH.GetPrefs)
|
|
r.Put("/prefs", s.notificationH.UpdatePrefs)
|
|
// Legacy single-toggle endpoints — shims over /prefs, kept until
|
|
// old clients are gone.
|
|
r.Get("/email-prefs", s.notificationH.GetEmailPrefs)
|
|
r.Put("/email-prefs", s.notificationH.UpdateEmailPrefs)
|
|
})
|
|
|
|
// --- Web Push --- VAPID key + per-user subscription management.
|
|
r.Route("/push", func(r chi.Router) {
|
|
if s.pushH == nil {
|
|
r.HandleFunc("/*", s.authUnavailable)
|
|
return
|
|
}
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Get("/key", s.pushH.Key)
|
|
r.Post("/subscribe", s.pushH.Subscribe)
|
|
r.Post("/unsubscribe", s.pushH.Unsubscribe)
|
|
})
|
|
|
|
// --- Sādhana streak --- daily chanting log + growing-plant state.
|
|
// All endpoints require a signed-in user.
|
|
r.Route("/sadhna", func(r chi.Router) {
|
|
if s.sadhnaH == nil {
|
|
r.HandleFunc("/*", s.authUnavailable)
|
|
return
|
|
}
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Get("/dashboard", s.sadhnaH.Dashboard)
|
|
r.Get("/history", s.sadhnaH.History)
|
|
r.Post("/chant", s.sadhnaH.SaveChant)
|
|
r.Get("/rewards", s.sadhnaH.Rewards)
|
|
r.Post("/rewards/ack", s.sadhnaH.AckRewards)
|
|
if s.reminderH != nil {
|
|
r.Get("/reminders", s.reminderH.GetPrefs)
|
|
r.Put("/reminders", s.reminderH.UpdatePrefs)
|
|
r.Post("/timezone", s.reminderH.SetTimezone)
|
|
}
|
|
})
|
|
|
|
// --- Admin panel --- every route gated by RequireAuth + RequireAdmin
|
|
// (role == "admin"), so the whole surface is invisible to ordinary
|
|
// accounts. Additive: no existing route or payload changes.
|
|
r.Route("/admin", func(r chi.Router) {
|
|
if s.adminH == nil {
|
|
r.HandleFunc("/*", s.authUnavailable)
|
|
return
|
|
}
|
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
|
r.Use(middleware.RequireAdmin(s.userRepo))
|
|
r.Get("/stats", s.adminH.Stats)
|
|
r.Get("/users", s.adminH.ListUsers)
|
|
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
|
|
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
|
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
|
r.Post("/users/{id}/limits", s.adminH.SetUserLimits)
|
|
// Direct-disciple linking: mark an account as one of Śrīla
|
|
// Prabhupāda's initiated disciples (or clear the mark).
|
|
r.Get("/disciples", s.adminH.ListDisciples)
|
|
r.Post("/users/{id}/disciple", s.adminH.LinkDisciple)
|
|
r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
|
|
r.Get("/posts", s.adminH.ListPosts)
|
|
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
|
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
|
r.Delete("/posts/{id}", s.adminH.DeletePost)
|
|
r.Post("/posts/{id}/restore", s.adminH.RestorePost)
|
|
r.Delete("/comments/{id}", s.adminH.DeleteComment)
|
|
r.Post("/comments/{id}/restore", s.adminH.RestoreComment)
|
|
r.Get("/collections", s.adminH.ListCollections)
|
|
r.Get("/feedback", s.adminH.ListFeedback)
|
|
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
|
r.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
|
|
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
|
r.Get("/reports", s.adminH.ListReports)
|
|
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
|
r.Get("/short-urls", s.adminH.ListShortURLs)
|
|
r.Get("/short-urls/stats", s.adminH.ShortURLStats)
|
|
})
|
|
})
|
|
|
|
s.router = r
|
|
}
|