package main import ( "context" "database/sql" "flag" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time" _ "github.com/jackc/pgx/v5/stdlib" "golang.org/x/crypto/bcrypt" "golang.org/x/term" "git.soup.land/soup/sxgo/ssr" "lookbook/internal/data/admin" "lookbook/internal/data/session" "lookbook/internal/handlers" "lookbook/internal/middleware" "lookbook/internal/migrations" "lookbook/internal/static" ) const defaultAddr = ":8080" func main() { migrate := flag.Bool("migrate", false, "run database migrations and exit") rollback := flag.Bool("rollback", false, "roll back migrations and exit (one step by default)") rollbackTarget := flag.Int64("to", -1, "target version for rollback when using -rollback") dbURLFlag := flag.String("db-url", "", "database connection URL") flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, })) dbURL := *dbURLFlag if dbURL == "" { dbURL = os.Getenv("DATABASE_URL") } if dbURL == "" { dbURL = migrations.DefaultURL } switch { case *migrate && *rollback: logger.Error("choose either -migrate or -rollback, not both") os.Exit(1) case *migrate: if err := migrations.Up(context.Background(), dbURL, logger); err != nil { logger.Error("migration failed", slog.Any("err", err)) os.Exit(1) } return case *rollback: if err := migrations.Down(context.Background(), dbURL, *rollbackTarget, logger); err != nil { logger.Error("rollback failed", slog.Any("err", err)) os.Exit(1) } return } // Check for subcommand args := flag.Args() if len(args) < 1 { printUsage() os.Exit(1) } mode := args[0] switch mode { case "web": runWebServer(dbURL, logger) case "set-password": runSetPassword(dbURL, logger) default: logger.Error("unknown mode", slog.String("mode", mode)) printUsage() os.Exit(1) } } func printUsage() { fmt.Fprintln(os.Stderr, "Usage:") fmt.Fprintln(os.Stderr, " lookbook web - Run web server") fmt.Fprintln(os.Stderr, " lookbook set-password - Set/reset admin password") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Flags:") fmt.Fprintln(os.Stderr, " -migrate Run database migrations") fmt.Fprintln(os.Stderr, " -rollback Roll back migrations") fmt.Fprintln(os.Stderr, " -to Target version for rollback") fmt.Fprintln(os.Stderr, " -db-url Database connection URL") } func runWebServer(dbURL string, logger *slog.Logger) { // Check for pending migrations pending, err := migrations.CheckPending(context.Background(), dbURL, logger) if err != nil { logger.Warn("could not check migration status", slog.Any("err", err)) } else if pending > 0 { logger.Warn("database has pending migrations", slog.Int("pending", pending), slog.String("hint", "run 'make migrate' to apply")) } db, err := sql.Open("pgx", dbURL) if err != nil { logger.Error("failed to open database", slog.Any("err", err)) os.Exit(1) } defer db.Close() if err := db.Ping(); err != nil { logger.Error("failed to ping database", slog.Any("err", err)) os.Exit(1) } rc := &handlers.RequestContext{ DB: db, Logger: logger, TmplCache: ssr.NewTmplCache(handlers.TemplateFuncs), } router := handlers.NewRouter(rc) // Pages router.Handle("GET /", handlers.HandleHome) router.Handle("GET /item/{id}", handlers.HandleItemPage) // Static files router.HandleStd("GET /static/{version}/", static.Handler()) // Media router.Handle("GET /media/{id}", handlers.HandleGetMedia) router.Handle("GET /proxy/video/{id}", handlers.HandleProxyVideo) // Auth API router.Handle("POST /api/auth/login", handlers.HandleLogin) router.Handle("POST /api/auth/logout", handlers.HandleLogout) router.Handle("GET /api/auth/status", handlers.HandleAuthStatus) router.Handle("POST /api/auth/password", handlers.HandleChangePassword) // Items API router.Handle("GET /api/items", handlers.HandleListItems) router.Handle("GET /api/items/{id}", handlers.HandleGetItem) router.Handle("POST /api/items", handlers.HandleCreateItem) router.Handle("PUT /api/items/{id}", handlers.HandleUpdateItem) router.Handle("DELETE /api/items/{id}", handlers.HandleDeleteItem) // Item creation endpoints router.Handle("POST /api/preview", handlers.HandlePreviewLink) router.Handle("POST /api/items/from-link", handlers.HandleCreateFromLink) router.Handle("POST /api/items/upload", handlers.HandleUpload) router.Handle("POST /api/items/quote", handlers.HandleCreateQuote) router.Handle("POST /api/items/{id}/refresh", handlers.HandleRefreshMetadata) router.Handle("POST /api/items/{id}/media", handlers.HandleReplaceMedia) // Tags API router.Handle("GET /api/tags", handlers.HandleListTags) router.Handle("GET /api/tags/suggest", handlers.HandleSuggestTags) addr := defaultAddr if envAddr := os.Getenv("ADDR"); envAddr != "" { addr = envAddr } server := &http.Server{ Addr: addr, Handler: middleware.Logging(logger)(router), ReadHeaderTimeout: 5 * time.Second, } go func() { logger.Info("listening", slog.String("addr", addr)) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { logger.Error("http server error", slog.Any("err", err)) os.Exit(1) } }() waitForShutdown(server, logger) } func waitForShutdown(server *http.Server, logger *slog.Logger) { quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) <-quit logger.Info("shutting down") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { logger.Error("graceful shutdown failed", slog.Any("err", err)) } } func runSetPassword(dbURL string, logger *slog.Logger) { fmt.Print("Enter new password: ") password, err := readPassword() if err != nil { logger.Error("failed to read password", slog.Any("err", err)) os.Exit(1) } if password == "" { logger.Error("password cannot be empty") os.Exit(1) } fmt.Print("Confirm password: ") confirm, err := readPassword() if err != nil { logger.Error("failed to read confirmation", slog.Any("err", err)) os.Exit(1) } if password != confirm { logger.Error("passwords do not match") os.Exit(1) } db, err := sql.Open("pgx", dbURL) if err != nil { logger.Error("failed to open database", slog.Any("err", err)) os.Exit(1) } defer db.Close() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { logger.Error("failed to hash password", slog.Any("err", err)) os.Exit(1) } if err := admin.QSetPassword(ctx, db, hash); err != nil { logger.Error("failed to set password", slog.Any("err", err)) os.Exit(1) } // Invalidate all sessions if err := session.QDeleteAll(ctx, db); err != nil { logger.Error("failed to clear sessions", slog.Any("err", err)) os.Exit(1) } logger.Info("password updated, all sessions invalidated") } func readPassword() (string, error) { fd := int(os.Stdin.Fd()) if term.IsTerminal(fd) { bytes, err := term.ReadPassword(fd) fmt.Println() // newline after password input return string(bytes), err } // Fallback for non-terminal (piped input) var password string _, err := fmt.Scanln(&password) return password, err }