osaet/backend/cmd/osaet-admin/main.go
yarnom 49a0d078da
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Add R2 image uploads to admin
2026-06-04 14:31:45 +08:00

308 lines
7.6 KiB
Go

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 <username>")
}
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
}