Session (no env needed): - SESSION_COOKIE_DOMAIN env removed. Config.SessionCookieParentDomain() derives the cookie Domain from WEB_ORIGIN + PUBLIC_API_URL, which the API already requires: sibling hosts of one parent (descrybe.io + api.descrybe.io) share the parent domain so SvelteKit SSR (/admin gate, user switching) receives the session cookie; localhost, IPs, same-host, and unrelated hosts stay host-only. Deploying the new build is the whole fix — nothing to configure. AI generation prompt page: - Each section now embeds its formula editor next to the per-language prompt instructions: Title = full title formula builder (preview, elements, separator, variable selector, custom variables), Description = description formula sections editor (type + instructions + export id, drag reorder), Meta = meta title / meta description formula fields. One Save writes categories.prompt + title_template + description_template together; Assign copies all three to the selected categories. - New $lib/categories/formula-variables.ts loads every usable field for the builder: custom variables (/api/variables), company attributes (/api/attributes — attribute_key, name, unit, example), and standard fields (/api/standard-fields). Used by both the prompt page and the title-formula page (which previously ignored attributes). Verified locally: svelte-check clean for changed files, unit tests pass, and the full save contract exercised over HTTP as the page does it (login → load variables/attributes/standard-fields → PATCH prompt + title-formula + description-formula → round-trip read), then the test category restored via repair-category-prompts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
117 lines
3.9 KiB
Go
117 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/db"
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/httpapi"
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/logredact"
|
|
)
|
|
|
|
func main() {
|
|
log.SetOutput(logredact.Writer(os.Stderr))
|
|
slog.SetDefault(slog.New(logredact.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
log.Fatalf("config: %v", err)
|
|
}
|
|
if cfg.InsecureLocalProductionActive() {
|
|
slog.Warn("ALLOW_INSECURE_LOCAL_PRODUCTION=1 with loopback WEB_ORIGIN — not for public deploy",
|
|
"web_origin", cfg.WebOrigin,
|
|
"http_addr", cfg.HTTPAddr,
|
|
)
|
|
}
|
|
if cfg.ShouldWarnRateLimits() {
|
|
slog.Warn(cfg.RateLimitWarningMessage(),
|
|
"rate_limit_replicas", cfg.RateLimitReplicas,
|
|
"rate_limit_multi_replica", cfg.RateLimitMultiReplica,
|
|
"rate_limit_backend", cfg.RateLimitBackend,
|
|
"rate_limit_backend_requested", cfg.RateLimitBackendRequested,
|
|
)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
pool, err := db.NewPool(ctx, cfg.DatabaseURL, db.PoolOptions{
|
|
MaxConns: int32(cfg.DBMaxConns),
|
|
MinConns: int32(cfg.DBMinConns),
|
|
MaxConnLifetime: cfg.DBMaxConnLifetime,
|
|
MaxConnLifetimeJitter: cfg.DBMaxConnLifetimeJitter,
|
|
MaxConnIdleTime: cfg.DBMaxConnIdleTime,
|
|
HealthCheckPeriod: cfg.DBHealthCheckPeriod,
|
|
StatementTimeout: cfg.DBStatementTimeout,
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("db: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
sessions := auth.NewSessionManager(pool, cfg.SessionCookieName, cfg.SessionCookieParentDomain(), cfg.CookieSecure(), cfg.SessionIdleHours)
|
|
srv := httpapi.NewServer(cfg, pool, sessions)
|
|
|
|
runCtx, runCancel := context.WithCancel(context.Background())
|
|
defer runCancel()
|
|
// Lightweight AI fallback poller so FAQ-miss tickets drain without a separate worker.
|
|
if srv.Support != nil {
|
|
go srv.Support.RunAutoJobsLoop(runCtx, 2*time.Second, 3)
|
|
}
|
|
|
|
httpServer := newHTTPServer(cfg.HTTPAddr, srv.Router())
|
|
|
|
ln, err := net.Listen("tcp", cfg.HTTPAddr)
|
|
if err != nil {
|
|
log.Fatalf("listen: %v", err)
|
|
}
|
|
slog.Info("api_listening", "addr", cfg.HTTPAddr, "maintenance", cfg.MaintenanceMode, "read_only", cfg.ReadOnlyMode)
|
|
|
|
go func() {
|
|
if err := httpServer.Serve(ln); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("serve: %v", err)
|
|
}
|
|
}()
|
|
|
|
stop := make(chan os.Signal, 1)
|
|
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
|
<-stop
|
|
runCancel()
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = httpServer.Shutdown(shutdownCtx)
|
|
}
|
|
|
|
// newHTTPServer configures net/http timeouts for the API listener.
|
|
//
|
|
// Tradeoff — WriteTimeout vs long sync/export:
|
|
// WriteTimeout bounds the whole ServeHTTP + response write. Feed export
|
|
// streams and sync-style handlers can run for many minutes; a short
|
|
// WriteTimeout aborts them mid-stream (clients see truncated/hanging
|
|
// responses). We use a long WriteTimeout ceiling instead of 0 (unlimited)
|
|
// so wedged handlers still release connections eventually. Finer per-route
|
|
// deadlines belong on request contexts / middleware for normal JSON APIs.
|
|
// Leaving WriteTimeout unset (0) would never reclaim a stuck writer.
|
|
func newHTTPServer(addr string, handler http.Handler) *http.Server {
|
|
return &http.Server{
|
|
Addr: addr,
|
|
Handler: handler,
|
|
// Headers-only Slowloris guard (independent of ReadTimeout).
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
// Full request read (headers + body). Above typical API JSON uploads.
|
|
ReadTimeout: 60 * time.Second,
|
|
// Long ceiling so streaming exports/sync can finish; see comment above.
|
|
WriteTimeout: 15 * time.Minute,
|
|
// Close keep-alive connections idle between requests.
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
}
|