327 lines
9.8 KiB
Go
327 lines
9.8 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"os/signal"
|
||
"strconv"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
"infinite-canvas/backend/internal/config"
|
||
"infinite-canvas/backend/internal/database"
|
||
"infinite-canvas/backend/internal/handler"
|
||
"infinite-canvas/backend/internal/repository"
|
||
"infinite-canvas/backend/internal/service"
|
||
"infinite-canvas/backend/internal/updaterclient"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
func main() {
|
||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||
defer stop()
|
||
if err := run(ctx); err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func run(ctx context.Context) error {
|
||
// 先加载仓库根的 .env(若存在),再读取环境变量,保证直连 `go run` 也能读到配置。
|
||
config.LoadDotEnv()
|
||
dataDir := env("CANVAS_BACKEND_DATA_DIR", "data")
|
||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
db, err := database.Open(database.Config{
|
||
Driver: env("CANVAS_DATABASE_DRIVER", "sqlite"),
|
||
DSN: os.Getenv("DATABASE_URL"),
|
||
DataDir: dataDir,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := database.ConfigurePool(db); err != nil {
|
||
return err
|
||
}
|
||
log.Printf("database driver: %s (DATABASE_URL %s)", db.Dialector.Name(), map[bool]string{true: "set", false: "empty"}[strings.TrimSpace(os.Getenv("DATABASE_URL")) != ""])
|
||
sqlDB, err := db.DB()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer sqlDB.Close()
|
||
autoMigrate, err := envBool("CANVAS_AUTO_MIGRATE", true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if autoMigrate {
|
||
err = database.MigrateSchema(db)
|
||
} else {
|
||
err = database.RequireSchemaVersion(db)
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
repo := repository.New(db)
|
||
// 本项目本地开发端口 7381,避开上游默认的 8080(见 AGENTS.md「本项目附加约定」)。
|
||
addr := env("CANVAS_BACKEND_ADDR", ":7381")
|
||
svc := service.New(repo, dataDir)
|
||
if updaterToken := strings.TrimSpace(os.Getenv("CANVAS_UPDATER_TOKEN")); updaterToken != "" {
|
||
svc.ConfigureUpdateManager(updaterclient.New(env("CANVAS_UPDATER_SOCKET", "/run/open-ai-canvas-updater/updater.sock"), updaterToken))
|
||
}
|
||
defer svc.Close()
|
||
if err := svc.ValidateRuntime(); err != nil {
|
||
return err
|
||
}
|
||
if err := svc.EnsureSystemChannelModels(); err != nil {
|
||
return err
|
||
}
|
||
if err := svc.EnsureDefaultPromptTemplates(); err != nil {
|
||
return err
|
||
}
|
||
if err := svc.EnsureBuiltinProjectWorkflowTemplate(); err != nil {
|
||
return err
|
||
}
|
||
if err := svc.EnsureBuiltinSkills(); err != nil {
|
||
return err
|
||
}
|
||
if err := svc.EnsureSkillPackages(); err != nil {
|
||
return err
|
||
}
|
||
if err := svc.EnsureBuiltinTools(); err != nil {
|
||
return err
|
||
}
|
||
if summary, err := svc.MigrateLegacyStorage(); err != nil {
|
||
log.Printf("storage migration skipped after error: %v", err)
|
||
} else if summary.Backup != "" {
|
||
log.Printf("storage migration completed: tasks=%d assets=%d projects=%d backup=%s", summary.Tasks, summary.Assets, summary.Projects, summary.Backup)
|
||
}
|
||
r := gin.New()
|
||
r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||
return fmt.Sprintf("%s - [%s] \"%s %s\" %d %s %s\n", param.ClientIP, param.TimeStamp.Format(time.RFC3339), param.Method, redactCanvasSharePath(param.Path), param.StatusCode, param.Latency, param.ErrorMessage)
|
||
}), gin.Recovery())
|
||
r.Use(handler.RequestCorrelationMiddleware())
|
||
corsMiddleware, err := cors()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
r.Use(corsMiddleware)
|
||
handler.ConfigureRuntime(svc)
|
||
api := r.Group("/api")
|
||
status := newSystemStatus(db, svc)
|
||
registerSystemStatusRoutes(api, status)
|
||
handler.RegisterOAuthCallbackRoutes(r, svc)
|
||
handler.RegisterCanvasAPI(api, svc)
|
||
r.NoRoute(handler.SystemProxyNoRouteHandler(svc))
|
||
|
||
listener, err := net.Listen("tcp", addr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
workerTimeout, err := envDuration("CANVAS_SHUTDOWN_TIMEOUT", 10*time.Minute)
|
||
if err != nil {
|
||
_ = listener.Close()
|
||
return err
|
||
}
|
||
httpServer := &http.Server{Handler: r, ReadHeaderTimeout: 10 * time.Second}
|
||
svc.StartWorker()
|
||
// 启动后回填存量视频的播放副本转码(幂等,无待处理项即退出)。
|
||
go svc.BackfillPlaybackTranscodes()
|
||
status.markStarted()
|
||
serveErr := make(chan error, 1)
|
||
go func() { serveErr <- httpServer.Serve(listener) }()
|
||
log.Printf("backend listening on %s", addr)
|
||
|
||
var serveFailure error
|
||
select {
|
||
case <-ctx.Done():
|
||
case err := <-serveErr:
|
||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||
serveFailure = fmt.Errorf("HTTP 服务异常退出:%w", err)
|
||
}
|
||
}
|
||
|
||
status.beginDrain()
|
||
var shutdownFailures []error
|
||
if serveFailure != nil {
|
||
shutdownFailures = append(shutdownFailures, serveFailure)
|
||
}
|
||
httpShutdownCtx, httpShutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer httpShutdownCancel()
|
||
if err := httpServer.Shutdown(httpShutdownCtx); err != nil {
|
||
_ = httpServer.Close()
|
||
shutdownFailures = append(shutdownFailures, fmt.Errorf("关闭 HTTP 服务:%w", err))
|
||
}
|
||
workerCtx, workerCancel := context.WithTimeout(context.Background(), workerTimeout)
|
||
defer workerCancel()
|
||
if err := svc.StopWorker(workerCtx); err != nil {
|
||
shutdownFailures = append(shutdownFailures, fmt.Errorf("等待后台任务退出:%w", err))
|
||
}
|
||
if err := errors.Join(shutdownFailures...); err != nil {
|
||
return err
|
||
}
|
||
log.Printf("backend stopped gracefully")
|
||
return nil
|
||
}
|
||
|
||
func redactCanvasSharePath(path string) string {
|
||
const prefix = "/api/public/canvas-shares/"
|
||
if !strings.HasPrefix(path, prefix) {
|
||
return path
|
||
}
|
||
remainder := strings.TrimPrefix(path, prefix)
|
||
if index := strings.IndexByte(remainder, '/'); index >= 0 {
|
||
return prefix + ":token" + remainder[index:]
|
||
}
|
||
return prefix + ":token"
|
||
}
|
||
|
||
func env(key string, fallback string) string {
|
||
value := os.Getenv(key)
|
||
if value == "" {
|
||
return fallback
|
||
}
|
||
return value
|
||
}
|
||
|
||
func envBool(key string, fallback bool) (bool, error) {
|
||
value := strings.TrimSpace(os.Getenv(key))
|
||
if value == "" {
|
||
return fallback, nil
|
||
}
|
||
parsed, err := strconv.ParseBool(value)
|
||
if err != nil {
|
||
return false, fmt.Errorf("%s 必须是 true 或 false", key)
|
||
}
|
||
return parsed, nil
|
||
}
|
||
|
||
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
||
value := strings.TrimSpace(os.Getenv(key))
|
||
if value == "" {
|
||
return fallback, nil
|
||
}
|
||
parsed, err := time.ParseDuration(value)
|
||
if err != nil || parsed <= 0 {
|
||
return 0, fmt.Errorf("%s 必须是正数时长,例如 10m", key)
|
||
}
|
||
return parsed, nil
|
||
}
|
||
|
||
const corsAllowedHeaders = "Accept, Content-Type, Authorization, X-Requested-With, X-Canvas-Scene, X-Idempotency-Key, X-Canvas-Trace-ID, X-Canvas-Upstream-URL, X-Canvas-Upstream-Format, X-Canvas-Upstream-Base-URL"
|
||
|
||
const corsAllowedMethods = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||
|
||
type corsPolicy struct {
|
||
origins map[string]struct{}
|
||
allowAny bool
|
||
}
|
||
|
||
func cors() (gin.HandlerFunc, error) {
|
||
policy, err := parseCORSPolicy(os.Getenv("CANVAS_CORS_ORIGINS"))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return func(c *gin.Context) {
|
||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||
if origin != "" && !allowedOriginWithPolicy(c, origin, policy) {
|
||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": http.StatusForbidden, "data": nil, "msg": "不允许的跨域来源"})
|
||
return
|
||
}
|
||
if origin != "" {
|
||
c.Header("Access-Control-Allow-Origin", origin)
|
||
c.Header("Access-Control-Allow-Credentials", "true")
|
||
c.Header("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
|
||
}
|
||
c.Header("Access-Control-Allow-Headers", corsAllowedHeaders)
|
||
c.Header("Access-Control-Expose-Headers", "X-Request-ID, X-Canvas-Trace-ID, X-Diagnostic-Bundle-ID, X-Diagnostic-Schema-Version")
|
||
c.Header("Access-Control-Allow-Methods", corsAllowedMethods)
|
||
c.Header("Access-Control-Max-Age", "86400")
|
||
if c.Request.Method == "OPTIONS" {
|
||
c.AbortWithStatus(204)
|
||
return
|
||
}
|
||
c.Next()
|
||
}, nil
|
||
}
|
||
|
||
func allowedOrigin(c *gin.Context, origin string) bool {
|
||
policy, err := parseCORSPolicy(os.Getenv("CANVAS_CORS_ORIGINS"))
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return allowedOriginWithPolicy(c, origin, policy)
|
||
}
|
||
|
||
func parseCORSPolicy(raw string) (corsPolicy, error) {
|
||
policy := corsPolicy{origins: make(map[string]struct{})}
|
||
for _, value := range strings.Split(raw, ",") {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
continue
|
||
}
|
||
if value == "*" {
|
||
policy.allowAny = true
|
||
continue
|
||
}
|
||
normalized, err := normalizeCORSOrigin(value)
|
||
if err != nil {
|
||
return corsPolicy{}, fmt.Errorf("CANVAS_CORS_ORIGINS contains invalid origin %q: %w", value, err)
|
||
}
|
||
policy.origins[normalized] = struct{}{}
|
||
}
|
||
return policy, nil
|
||
}
|
||
|
||
func normalizeCORSOrigin(raw string) (string, error) {
|
||
value := strings.TrimSpace(raw)
|
||
if value == "" {
|
||
return "", fmt.Errorf("origin is empty")
|
||
}
|
||
parsed, err := url.Parse(value)
|
||
if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||
return "", fmt.Errorf("origin must be an http or https origin")
|
||
}
|
||
if parsed.Path != "" && parsed.Path != "/" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||
return "", fmt.Errorf("origin must not contain a path, query, or fragment")
|
||
}
|
||
return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host), nil
|
||
}
|
||
|
||
func allowedOriginWithPolicy(c *gin.Context, origin string, policy corsPolicy) bool {
|
||
normalizedOrigin, err := normalizeCORSOrigin(origin)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
parsed, err := url.Parse(normalizedOrigin)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
requestHost := c.Request.Host
|
||
if forwardedHost := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" {
|
||
requestHost = strings.TrimSpace(strings.Split(forwardedHost, ",")[0])
|
||
}
|
||
if strings.EqualFold(parsed.Host, strings.TrimSpace(requestHost)) {
|
||
return true
|
||
}
|
||
if policy.allowAny {
|
||
return true
|
||
}
|
||
if _, ok := policy.origins[normalizedOrigin]; ok {
|
||
return true
|
||
}
|
||
if len(policy.origins) > 0 {
|
||
return false
|
||
}
|
||
host := strings.ToLower(parsed.Hostname())
|
||
return (host == "localhost" || host == "127.0.0.1" || host == "::1") && (parsed.Scheme == "http" || parsed.Scheme == "https")
|
||
}
|