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/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
|
||||
"github.com/typesense/typesense-go/v2/typesense"
|
||||
"github.com/typesense/typesense-go/v2/typesense/api"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -191,6 +192,15 @@ func main() {
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -66,6 +66,10 @@ type Config struct {
|
||||
// Frontend
|
||||
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
|
||||
// to build the OAuth redirect_uri the provider sees (must match the
|
||||
// allow-list configured in the provider's console). In dev this is
|
||||
@@ -147,7 +151,8 @@ func Load() (*Config, error) {
|
||||
CookieDomain: getEnv("COOKIE_DOMAIN", ""),
|
||||
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"),
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -163,6 +164,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
}, 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.
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -45,12 +46,17 @@ type AuthService struct {
|
||||
mail mailer.Mailer
|
||||
logger *zap.Logger
|
||||
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.
|
||||
PasswordResetTTL 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
|
||||
// the OAuth flows aren't wired (handler then returns 503).
|
||||
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))
|
||||
}
|
||||
|
||||
// New public profile → refresh the sitemap so it's crawlable promptly.
|
||||
s.reval.Trigger("sitemap")
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
// New public profile → refresh the sitemap so it's crawlable promptly.
|
||||
s.reval.Trigger("sitemap")
|
||||
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