feat(seo): add sitemap generator
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/config"
|
"github.com/prabhupadasays/spsays-bff/internal/config"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
|
||||||
"github.com/typesense/typesense-go/v2/typesense"
|
"github.com/typesense/typesense-go/v2/typesense"
|
||||||
"github.com/typesense/typesense-go/v2/typesense/api"
|
"github.com/typesense/typesense-go/v2/typesense/api"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
@@ -191,6 +192,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sugar.Info("=== Indexing complete ===")
|
sugar.Info("=== Indexing complete ===")
|
||||||
|
|
||||||
|
// Content changed → refresh the frontend sitemap cache (synchronous: this
|
||||||
|
// CLI exits right after, so a background goroutine wouldn't run). No-op
|
||||||
|
// when FRONTEND_URL / REVALIDATE_SECRET are unset.
|
||||||
|
if err := revalidate.New(cfg.FrontendURL, cfg.RevalidateSecret, logger).TriggerSync("sitemap"); err != nil {
|
||||||
|
sugar.Warnf("sitemap revalidation ping failed: %v", err)
|
||||||
|
} else if cfg.RevalidateSecret != "" {
|
||||||
|
sugar.Info("sitemap revalidation pinged")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// importBatch imports documents into Typesense in batches of batchSize.
|
// importBatch imports documents into Typesense in batches of batchSize.
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ type Config struct {
|
|||||||
// Frontend
|
// Frontend
|
||||||
FrontendURL string // for CORS + reset/verify links in emails
|
FrontendURL string // for CORS + reset/verify links in emails
|
||||||
|
|
||||||
|
// RevalidateSecret authenticates BFF→frontend on-demand cache
|
||||||
|
// revalidation pings (POST /api/revalidate). Empty disables the pings.
|
||||||
|
RevalidateSecret string
|
||||||
|
|
||||||
// BFF public URL — the externally-reachable origin of this server. Used
|
// BFF public URL — the externally-reachable origin of this server. Used
|
||||||
// to build the OAuth redirect_uri the provider sees (must match the
|
// to build the OAuth redirect_uri the provider sees (must match the
|
||||||
// allow-list configured in the provider's console). In dev this is
|
// allow-list configured in the provider's console). In dev this is
|
||||||
@@ -148,6 +152,7 @@ func Load() (*Config, error) {
|
|||||||
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
||||||
|
|
||||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||||
|
RevalidateSecret: getEnv("REVALIDATE_SECRET", ""),
|
||||||
|
|
||||||
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
|
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -163,6 +164,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
}, logger)
|
}, logger)
|
||||||
|
|
||||||
authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL)
|
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)
|
userSvc := service.NewUserService(userRepo, r2)
|
||||||
|
|
||||||
// Social repositories + services share the same Postgres pool and R2 client.
|
// Social repositories + services share the same Postgres pool and R2 client.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@@ -45,12 +46,17 @@ type AuthService struct {
|
|||||||
mail mailer.Mailer
|
mail mailer.Mailer
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
frontend string // FrontendURL — used to build reset / verify links
|
frontend string // FrontendURL — used to build reset / verify links
|
||||||
|
reval *revalidate.Client // optional — pings sitemap revalidation on new signups
|
||||||
|
|
||||||
// TTLs — surfaced as fields so tests can shrink them.
|
// TTLs — surfaced as fields so tests can shrink them.
|
||||||
PasswordResetTTL time.Duration
|
PasswordResetTTL time.Duration
|
||||||
EmailVerifyTTL time.Duration
|
EmailVerifyTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRevalidator wires the frontend cache-revalidation client. Optional —
|
||||||
|
// when unset, new-signup sitemap pings are simply skipped.
|
||||||
|
func (s *AuthService) SetRevalidator(r *revalidate.Client) { s.reval = r }
|
||||||
|
|
||||||
// NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if
|
// NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if
|
||||||
// the OAuth flows aren't wired (handler then returns 503).
|
// the OAuth flows aren't wired (handler then returns 503).
|
||||||
func NewAuthService(
|
func NewAuthService(
|
||||||
@@ -140,6 +146,9 @@ func (s *AuthService) SignUp(ctx context.Context, email, password, name string)
|
|||||||
s.logger.Warn("verification email failed", zap.String("user", u.ID), zap.Error(err))
|
s.logger.Warn("verification email failed", zap.String("user", u.ID), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New public profile → refresh the sitemap so it's crawlable promptly.
|
||||||
|
s.reval.Trigger("sitemap")
|
||||||
|
|
||||||
return s.issueSession(u)
|
return s.issueSession(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +454,8 @@ func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*
|
|||||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// New public profile → refresh the sitemap so it's crawlable promptly.
|
||||||
|
s.reval.Trigger("sitemap")
|
||||||
return s.issueSession(u)
|
return s.issueSession(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Package revalidate pings the frontend's on-demand cache revalidation
|
||||||
|
// endpoint so sitemap-relevant changes (new signups, content re-seeds) surface
|
||||||
|
// within seconds instead of waiting out the frontend's daily ISR window.
|
||||||
|
package revalidate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client posts to <frontend>/api/revalidate. Safe to use when nil or when the
|
||||||
|
// frontend URL / secret are unset — Trigger becomes a no-op (so dev / misconfig
|
||||||
|
// never breaks the calling flow).
|
||||||
|
type Client struct {
|
||||||
|
base string
|
||||||
|
secret string
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(frontendURL, secret string, logger *zap.Logger) *Client {
|
||||||
|
return &Client{
|
||||||
|
base: trimRightSlash(frontendURL),
|
||||||
|
secret: secret,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger fires a fire-and-forget POST to revalidate the given cache tag.
|
||||||
|
// Never blocks the caller and never errors out of band — use from request
|
||||||
|
// handlers (e.g. signup) on a long-lived server.
|
||||||
|
func (c *Client) Trigger(tag string) {
|
||||||
|
if c == nil || c.base == "" || c.secret == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := c.TriggerSync(tag); err != nil && c.logger != nil {
|
||||||
|
c.logger.Warn("revalidate ping failed", zap.String("tag", tag), zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerSync POSTs synchronously and returns any error. Use from short-lived
|
||||||
|
// CLIs (e.g. the indexer) that would exit before a background goroutine ran.
|
||||||
|
// No-op (nil) when the client / URL / secret is unset.
|
||||||
|
func (c *Client) TriggerSync(tag string) error {
|
||||||
|
if c == nil || c.base == "" || c.secret == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
endpoint := fmt.Sprintf("%s/api/revalidate?tag=%s&secret=%s",
|
||||||
|
c.base, url.QueryEscape(tag), url.QueryEscape(c.secret))
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("revalidate returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimRightSlash(s string) string {
|
||||||
|
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user