579 lines
19 KiB
Go
579 lines
19 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"path/filepath"
|
|
|
|
"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/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
|
|
|
|
// Worker lifecycle
|
|
workerCancel context.CancelFunc
|
|
|
|
// 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
|
|
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.
|
|
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)
|
|
|
|
s := &Server{
|
|
cfg: cfg,
|
|
logger: logger,
|
|
mongo: mongoDB,
|
|
|
|
// Initialize handlers
|
|
bookH: handler.NewBookHandler(bookRepo),
|
|
letterH: handler.NewLetterHandler(letterRepo),
|
|
essayH: handler.NewEssayHandler(essayRepo),
|
|
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
|
|
discipleH: handler.NewDiscipleHandler(discipleRepo),
|
|
journalH: handler.NewJournalHandler(journalRepo),
|
|
magazineH: handler.NewMagazineHandler(magazineRepo),
|
|
otherDocH: handler.NewOtherDocumentHandler(otherDocRepo),
|
|
transcriptionH: handler.NewTranscriptionHandler(transcriptionRepo),
|
|
searchH: handler.NewSearchHandler(tsClient),
|
|
}
|
|
|
|
// 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)
|
|
|
|
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.
|
|
func (s *Server) initShortener(cfg *config.Config, logger *zap.Logger) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.workerCancel = cancel
|
|
|
|
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")
|
|
}
|
|
|
|
// 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)
|
|
r.Use(chiMiddleware.RealIP)
|
|
r.Use(middleware.Logger(s.logger))
|
|
r.Use(chiMiddleware.Recoverer)
|
|
r.Use(chiMiddleware.Compress(5))
|
|
|
|
// CORS
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: []string{s.cfg.FrontendURL},
|
|
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"}`))
|
|
})
|
|
|
|
// API v1
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
// Books
|
|
r.Route("/books", func(r chi.Router) {
|
|
r.Get("/", s.bookH.ListBooks)
|
|
|
|
// --- Verse books with divisions ---
|
|
|
|
// SB — canto divisions
|
|
r.Route("/sb", func(r chi.Router) {
|
|
r.Use(injectBookCode("sb"))
|
|
r.Get("/", s.bookH.GetBook)
|
|
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
|
|
r.Route("/cc", func(r chi.Router) {
|
|
r.Use(injectBookCode("cc"))
|
|
r.Get("/", s.bookH.GetBook)
|
|
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 ---
|
|
// BG, SB1962: pure verse books
|
|
for _, code := range []string{"bg", "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) ---
|
|
// BS, MM, NBS: have both verses and prose intro segments
|
|
for _, code := range []string{"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)
|
|
})
|
|
|
|
// 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("/year/{year}", s.transcriptionH.GetByYear)
|
|
r.Get("/place/{place}", s.transcriptionH.GetByPlace)
|
|
r.Get("/type/{type}", s.transcriptionH.GetByType)
|
|
r.Get("/adjacent/{slug}", s.transcriptionH.GetAdjacent)
|
|
r.Get("/{slug}", s.transcriptionH.GetBySlug)
|
|
})
|
|
|
|
// Search (Typesense)
|
|
r.Get("/search", s.searchH.Search)
|
|
|
|
// --- 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("/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) {
|
|
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)
|
|
}
|
|
})
|
|
|
|
// Push Notifications
|
|
r.Route("/notifications", func(r chi.Router) {
|
|
// TODO: register push notification handlers
|
|
})
|
|
})
|
|
|
|
s.router = r
|
|
}
|