feat(seo): add sitemap generator

This commit is contained in:
Krispire Admin
2026-06-15 13:53:18 +05:30
parent bbe491ae13
commit aa61f4f0b1
5 changed files with 107 additions and 1 deletions
+10
View File
@@ -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.
+5
View File
@@ -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"),
+2
View File
@@ -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.
+11
View File
@@ -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)
} }
+78
View File
@@ -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
}