package main import ( "context" "database/sql" "errors" "fmt" "log" "net/http" "net/url" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "github.com/jackc/pgx/v5/pgxpool" "osaet/backend/internal/admin" ) func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } func run() error { command := "serve" if len(os.Args) > 1 { command = os.Args[1] } cfg := admin.LoadConfig() if err := cfg.Validate(); err != nil { return err } logCloser, err := admin.ConfigureLogging(cfg) if err != nil { return err } if logCloser != nil { defer logCloser.Close() } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() db, err := admin.OpenDatabase(ctx, cfg.DatabaseURL) if err != nil { return err } defer db.Close() switch command { case "serve": return serve(ctx, cfg, db) case "migrate": return admin.RunMigrations(ctx, db, cfg.MigrationsDir) case "create-user": return createUser(ctx, db) default: return fmt.Errorf("unknown command %q", command) } } func createUser(ctx context.Context, db *pgxpool.Pool) error { if len(os.Args) < 3 { return errors.New("usage: osaet-admin create-user ") } password := os.Getenv("OSAET_ADMIN_PASSWORD") if password == "" { return errors.New("OSAET_ADMIN_PASSWORD is required") } user, err := admin.NewStore(db).CreateOrUpdateUser(ctx, os.Args[2], password) if err != nil { return err } fmt.Fprintf(os.Stdout, "admin user %q is ready\n", user.Username) return nil } func serve(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error { logServeStartup(ctx, cfg, db) server := &http.Server{ Addr: cfg.Addr, Handler: admin.NewServerWithContext(ctx, db, cfg).Router(), ReadHeaderTimeout: 5 * time.Second, } errCh := make(chan error, 1) go func() { log.Printf("http server listening addr=%s", cfg.Addr) errCh <- server.ListenAndServe() }() select { case <-ctx.Done(): log.Printf("shutdown signal received") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { return err } log.Printf("http server stopped") return nil case err := <-errCh: if errors.Is(err, http.ErrServerClosed) { log.Printf("http server closed") return nil } return err } } func logServeStartup(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) { log.Printf("osaet-admin starting command=serve") log.Printf("config addr=%s repo_root=%s", cfg.Addr, cleanPath(cfg.RepoRoot)) log.Printf("paths posts=%s site=%s static=%s admin=%s migrations=%s", cleanPath(cfg.PostsDir), cleanPath(cfg.SiteDir), cleanPath(cfg.StaticDir), cleanPath(cfg.AdminDir), cleanPath(cfg.MigrationsDir), ) log.Printf("logging file=%s max_bytes=%d max_backups=%d", cleanPath(cfg.LogFile), cfg.LogMaxBytes, cfg.LogMaxBackups) log.Printf("database configured=%t %s", strings.TrimSpace(cfg.DatabaseURL) != "", databaseSummary(cfg.DatabaseURL)) log.Printf("r2 configured=%t endpoint=%s bucket=%s prefix=%s public_base_url=%s max_upload_bytes=%d", strings.TrimSpace(cfg.R2.Endpoint) != "" && strings.TrimSpace(cfg.R2.Bucket) != "" && strings.TrimSpace(cfg.R2.AccessKeyID) != "" && strings.TrimSpace(cfg.R2.SecretAccessKey) != "", r2EndpointHost(cfg.R2.Endpoint), r2BucketName(cfg.R2.Bucket), cleanR2Prefix(cfg.R2.Bucket, cfg.R2.Prefix), cfg.R2.PublicBaseURL, cfg.R2.MaxUploadBytes, ) logPathState("static output", cfg.StaticDir) logPathState("admin output", cfg.AdminDir) logPathState("posts snapshot", cfg.PostsDir) logMigrationState(ctx, db, cfg.MigrationsDir) logDatabaseInfo(ctx, db) log.Printf("routes public=/ admin=/admin api=/api/admin health=/healthz ready=/readyz") } func r2EndpointHost(endpoint string) string { if strings.TrimSpace(endpoint) == "" { return "" } parsed, err := url.Parse(endpoint) if err != nil { return "invalid" } return parsed.Host } func r2BucketName(bucket string) string { bucket = strings.Trim(strings.TrimSpace(bucket), "/") parts := strings.SplitN(bucket, "/", 2) return parts[0] } func cleanR2Prefix(bucket string, prefix string) string { bucket = strings.Trim(strings.TrimSpace(bucket), "/") prefix = strings.Trim(strings.TrimSpace(prefix), "/") parts := strings.SplitN(bucket, "/", 2) if len(parts) == 2 && prefix != "" { return parts[1] + "/" + prefix } if len(parts) == 2 { return parts[1] } return prefix } func logDatabaseInfo(ctx context.Context, db *pgxpool.Pool) { if db == nil { log.Printf("database pool unavailable") return } var database string var user string var serverAddr sql.NullString var serverPort sql.NullInt32 var serverVersion string var timezone string err := db.QueryRow(ctx, ` SELECT current_database(), current_user, inet_server_addr()::text, inet_server_port(), current_setting('server_version'), current_setting('TimeZone')`).Scan( &database, &user, &serverAddr, &serverPort, &serverVersion, &timezone, ) if err != nil { log.Printf("database info failed: %v", err) return } addr := "local" if serverAddr.Valid { addr = serverAddr.String } port := int32(0) if serverPort.Valid { port = serverPort.Int32 } stats := db.Stat() log.Printf("database connected db=%s user=%s server=%s port=%d postgres=%s timezone=%s", database, user, addr, port, serverVersion, timezone) log.Printf("database pool total=%d idle=%d acquired=%d max=%d", stats.TotalConns(), stats.IdleConns(), stats.AcquiredConns(), stats.MaxConns()) } func logMigrationState(ctx context.Context, db *pgxpool.Pool, migrationsDir string) { migrations, err := admin.LoadMigrationFiles(migrationsDir) if err != nil { log.Printf("migrations dir=%s status=unreadable error=%v", cleanPath(migrationsDir), err) return } var tableExists bool err = db.QueryRow(ctx, `SELECT to_regclass('public.admin_schema_migrations') IS NOT NULL`).Scan(&tableExists) if err != nil { log.Printf("migrations files=%d applied=unknown error=%v", len(migrations), err) return } if !tableExists { log.Printf("migrations files=%d applied=0 table=missing", len(migrations)) return } var applied int if err := db.QueryRow(ctx, `SELECT count(*) FROM admin_schema_migrations`).Scan(&applied); err != nil { log.Printf("migrations files=%d applied=unknown error=%v", len(migrations), err) return } log.Printf("migrations files=%d applied=%d pending=%d", len(migrations), applied, maxInt(len(migrations)-applied, 0)) } func logPathState(label string, path string) { info, err := os.Stat(path) if err != nil { log.Printf("path %s=%s status=missing error=%v", label, cleanPath(path), err) return } if info.IsDir() { log.Printf("path %s=%s status=dir", label, cleanPath(path)) return } log.Printf("path %s=%s status=file size=%d", label, cleanPath(path), info.Size()) } func databaseSummary(databaseURL string) string { if strings.TrimSpace(databaseURL) == "" { return "dsn=empty" } parsed, err := url.Parse(databaseURL) if err != nil { return "dsn=unparseable" } user := "" if parsed.User != nil { user = parsed.User.Username() } dbName := strings.TrimPrefix(parsed.Path, "/") sslMode := parsed.Query().Get("sslmode") parts := []string{ "scheme=" + parsed.Scheme, "host=" + parsed.Host, "db=" + dbName, "user=" + user, } if sslMode != "" { parts = append(parts, "sslmode="+sslMode) } return strings.Join(parts, " ") } func cleanPath(path string) string { if path == "" { return "" } cleaned, err := filepath.Abs(path) if err != nil { return path } return cleaned } func maxInt(a int, b int) int { if a > b { return a } return b }