- Add POST /api/auth/password API endpoint
- Requires authentication and current password verification
- Invalidates all other sessions after password change
- Keeps current session active
- Add window.changePassword() console function
- Matches existing login flow pattern
- Usage: changePassword("current", "new")
- Add 'lookbook set-password' CLI command
- Interactive password reset (no current password required)
- Useful for recovery scenarios
- Invalidates all sessions
- Add session.QDeleteAllExcept() and session.QDeleteAll()
- Support for invalidating sessions after password change
268 lines
7.3 KiB
Go
268 lines
7.3 KiB
Go
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 <version> Target version for rollback")
|
|
fmt.Fprintln(os.Stderr, " -db-url <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
|
|
}
|