package main import ( "context" "fmt" "html/template" "log" "net/http" "os" "os/signal" "path/filepath" "syscall" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/jonradoff/flipbook/internal/auth" "github.com/jonradoff/flipbook/internal/config" "github.com/jonradoff/flipbook/internal/converter" "github.com/jonradoff/flipbook/internal/database" "github.com/jonradoff/flipbook/internal/handlers" "github.com/jonradoff/flipbook/internal/mcp" "github.com/jonradoff/flipbook/internal/storage" "github.com/jonradoff/flipbook/internal/worker" "golang.org/x/crypto/bcrypt" ) func main() { // Handle CLI subcommands if len(os.Args) > 1 { switch os.Args[1] { case "set-password": runSetPassword() return case "backfill-gridfs": runBackfillGridFS() return case "mcp": mcp.Run() return case "help": printHelp() return default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n", os.Args[1]) printHelp() os.Exit(1) } } runServer() } func printHelp() { fmt.Println("Usage: flipbook [command]") fmt.Println() fmt.Println("Commands:") fmt.Println(" (no command) Start the web server") fmt.Println(" set-password Set the admin password") fmt.Println(" backfill-gridfs Upload existing originals to GridFS backup") fmt.Println(" mcp Start the MCP server (stdin/stdout)") fmt.Println(" help Show this help message") } func runSetPassword() { cfg := config.Load() db, err := database.Open(context.Background(), cfg.MongoURI, cfg.MongoDB) if err != nil { log.Fatalf("Failed to connect to MongoDB: %v", err) } defer db.Close(context.Background()) fmt.Print("Enter new admin password: ") var password string fmt.Scanln(&password) if len(password) < 8 { fmt.Println("Error: Password must be at least 8 characters.") os.Exit(1) } hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { log.Fatalf("Failed to hash password: %v", err) } if err := db.SetSetting("admin_password_hash", string(hash)); err != nil { log.Fatalf("Failed to save password: %v", err) } fmt.Println("Admin password set successfully.") } func runBackfillGridFS() { cfg := config.Load() db, err := database.Open(context.Background(), cfg.MongoURI, cfg.MongoDB) if err != nil { log.Fatalf("Failed to connect to MongoDB: %v", err) } defer db.Close(context.Background()) store := storage.New(filepath.Join(cfg.DataDir, "flipbooks")) flipbooks, err := db.ListFlipbooks() if err != nil { log.Fatalf("Failed to list flipbooks: %v", err) } for _, fb := range flipbooks { if fb.GridFSFileID != "" { fmt.Printf(" SKIP %s (%s) — already backed up\n", fb.ID, fb.Title) continue } ext := filepath.Ext(fb.Filename) srcPath := store.OriginalPath(fb.ID, ext) f, err := os.Open(srcPath) if err != nil { fmt.Printf(" MISS %s (%s) — original not found: %s\n", fb.ID, fb.Title, srcPath) continue } gridfsID, err := db.UploadToGridFS(context.Background(), fb.Filename, f) f.Close() if err != nil { fmt.Printf(" FAIL %s (%s) — GridFS upload: %v\n", fb.ID, fb.Title, err) continue } if err := db.SetGridFSFileID(fb.ID, gridfsID); err != nil { fmt.Printf(" FAIL %s (%s) — save ID: %v\n", fb.ID, fb.Title, err) continue } fmt.Printf(" OK %s (%s) — backed up as %s\n", fb.ID, fb.Title, gridfsID) } fmt.Println("Backfill complete.") } func runServer() { cfg := config.Load() // Initialize database os.MkdirAll(cfg.DataDir, 0755) db, err := database.Open(context.Background(), cfg.MongoURI, cfg.MongoDB) if err != nil { log.Fatalf("Failed to connect to MongoDB: %v", err) } defer db.Close(context.Background()) // Initialize storage store := storage.New(filepath.Join(cfg.DataDir, "flipbooks")) // Initialize converter conv := converter.New(cfg.LibreOfficeBin, filepath.Join(cfg.DataDir, "tmp"), cfg.ConversionDPI, cfg.ThumbnailDPI) // Initialize worker w := worker.New(db, store, conv) ctx, cancel := context.WithCancel(context.Background()) defer cancel() w.Start(ctx) // Re-queue stuck conversions stuck, _ := db.GetFlipbooksByStatus("converting") for _, fb := range stuck { log.Printf("Re-queuing stuck conversion: %s", fb.ID) db.UpdateStatus(fb.ID, "pending", "") ext := filepath.Ext(fb.Filename) w.Enqueue(worker.Job{FlipbookID: fb.ID, SourcePath: store.OriginalPath(fb.ID, ext)}) } // Re-queue stuck regenerations regen, _ := db.GetFlipbooksByStatus("regenerating") for _, fb := range regen { log.Printf("Re-queuing stuck regeneration: %s", fb.ID) db.UpdateStatus(fb.ID, "pending", "") ext := filepath.Ext(fb.Filename) w.Enqueue(worker.Job{FlipbookID: fb.ID, SourcePath: store.OriginalPath(fb.ID, ext)}) } // Integrity check: detect ready flipbooks with missing page files and restore from GridFS go func() { readyFlipbooks, err := db.GetFlipbooksByStatus("ready") if err != nil { log.Printf("Integrity check: failed to query flipbooks: %v", err) return } for _, fb := range readyFlipbooks { if store.HasPages(fb.ID) { continue } if fb.GridFSFileID == "" { log.Printf("Integrity check: %s (%s) missing pages but no GridFS backup, skipping", fb.ID, fb.Title) continue } log.Printf("Integrity check: %s (%s) missing pages, restoring from GridFS", fb.ID, fb.Title) ext := filepath.Ext(fb.Filename) dstPath := store.OriginalPath(fb.ID, ext) if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { log.Printf("Integrity check: failed to create dir for %s: %v", fb.ID, err) continue } f, err := os.Create(dstPath) if err != nil { log.Printf("Integrity check: failed to create file for %s: %v", fb.ID, err) continue } _, err = db.DownloadFromGridFS(context.Background(), fb.GridFSFileID, f) f.Close() if err != nil { log.Printf("Integrity check: GridFS download failed for %s: %v", fb.ID, err) os.Remove(dstPath) db.UpdateStatus(fb.ID, "error", "Failed to restore from backup: "+err.Error()) continue } db.UpdateStatus(fb.ID, "regenerating", "") w.Enqueue(worker.Job{FlipbookID: fb.ID, SourcePath: dstPath}) } log.Println("Integrity check complete") }() // Parse templates tmpl := parseTemplates() // Initialize auth a := auth.New(db, cfg.SessionSecret, tmpl) // Warn if no password is set if !a.HasPassword() { log.Println("WARNING: No admin password set. Run './flipbook set-password' to secure the admin area.") } // Setup handlers adminH := handlers.NewAdminHandler(db, store, w, tmpl, cfg.BaseURL) viewerH := handlers.NewViewerHandler(db, store, tmpl, cfg.BaseURL) embedH := handlers.NewEmbedHandler(db, store, tmpl, cfg.BaseURL) apiH := handlers.NewAPIHandler(db, store, w, cfg.BaseURL) // Setup router r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Compress(5)) r.Use(securityHeaders) // Static files r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) // Serve flipbook images with caching (path-traversal safe via filepath.Base) r.Get("/data/flipbooks/{id}/pages/{filename}", func(rw http.ResponseWriter, req *http.Request) { rw.Header().Set("Cache-Control", "public, max-age=31536000, immutable") id := filepath.Base(chi.URLParam(req, "id")) fname := filepath.Base(chi.URLParam(req, "filename")) http.ServeFile(rw, req, filepath.Join(cfg.DataDir, "flipbooks", id, "pages", fname)) }) r.Get("/data/flipbooks/{id}/thumbs/{filename}", func(rw http.ResponseWriter, req *http.Request) { rw.Header().Set("Cache-Control", "public, max-age=31536000, immutable") id := filepath.Base(chi.URLParam(req, "id")) fname := filepath.Base(chi.URLParam(req, "filename")) http.ServeFile(rw, req, filepath.Join(cfg.DataDir, "flipbooks", id, "thumbs", fname)) }) // Auth routes (public) r.Get("/login", a.LoginPage) r.Post("/login", a.LoginSubmit) r.Post("/logout", a.LogoutHandler) // Admin routes (protected) r.Group(func(r chi.Router) { r.Use(a.RequireAuth) r.Get("/admin", adminH.Index) r.Get("/admin/upload", adminH.UploadForm) r.Post("/admin/upload", adminH.Upload) r.Post("/admin/import", adminH.ImportURL) r.Get("/admin/flipbooks/{id}", adminH.Detail) r.Post("/admin/flipbooks/{id}/delete", adminH.Delete) r.Post("/admin/flipbooks/{id}/settings", adminH.Settings) }) // API routes (protected by API key when set) r.Group(func(r chi.Router) { r.Use(apiAuth(cfg.APIKey)) r.Get("/api/flipbooks", apiH.ListFlipbooks) r.Post("/api/flipbooks", apiH.UploadFlipbook) r.Post("/api/flipbooks/import", apiH.ImportURL) r.Get("/api/flipbooks/{id}", apiH.GetFlipbook) r.Delete("/api/flipbooks/{id}", apiH.DeleteFlipbook) }) // Status endpoint (always accessible for upload progress tracking) r.Get("/api/flipbooks/{id}/status", apiH.FlipbookStatus) // Public viewers r.Get("/v/{slug}", viewerH.View) r.Get("/embed/{slug}", embedH.Embed) // Root redirect r.Get("/", func(rw http.ResponseWriter, req *http.Request) { http.Redirect(rw, req, "/admin", http.StatusFound) }) // Start server srv := &http.Server{ Addr: ":" + cfg.Port, Handler: r, ReadTimeout: 30 * time.Second, WriteTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second, ReadHeaderTimeout: 10 * time.Second, MaxHeaderBytes: 1 << 20, // 1MB } log.Printf("Flipbook server starting on :%s", cfg.Port) log.Printf("Admin UI: %s/admin", cfg.BaseURL) log.Printf("API key: %s", cfg.APIKey) log.Printf("LibreOffice: %s", cfg.LibreOfficeBin) // Graceful shutdown go func() { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) <-sigCh log.Println("Shutting down...") cancel() srv.Shutdown(context.Background()) }() if err := srv.ListenAndServe(); err != http.ErrServerClosed { log.Fatal(err) } } // securityHeaders adds standard security headers to all responses. func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "SAMEORIGIN") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") next.ServeHTTP(w, r) }) } // apiAuth middleware protects API routes with a bearer token. func apiAuth(apiKey string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") if token == "Bearer "+apiKey { next.ServeHTTP(w, r) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"unauthorized"}`)) }) } } func parseTemplates() *template.Template { funcMap := template.FuncMap{ "last": func(i, total int) bool { return i == total-1 }, "add": func(a, b int) int { return a + b }, } // Gather all template files var files []string patterns := []string{ "web/templates/*.html", "web/templates/admin/*.html", } for _, pattern := range patterns { matches, err := filepath.Glob(pattern) if err != nil { log.Printf("Warning: glob pattern %s: %v", pattern, err) continue } files = append(files, matches...) } if len(files) == 0 { log.Fatal("No template files found") } tmpl := template.Must(template.New("").Funcs(funcMap).ParseFiles(files...)) return tmpl }