package web import ( "context" "errors" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "github.com/a-h/templ" "github.com/arnef/ebooks/internal/library" "github.com/arnef/ebooks/internal/users" "github.com/arnef/ebooks/views" ) const maxUploadBytes = 512 << 20 // 512 MiB type Handler struct { lib *library.Service store *users.Store } func NewHandler(lib *library.Service, store *users.Store) *Handler { return &Handler{lib: lib, store: store} } func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Public mux.HandleFunc("GET /login", h.loginPage) mux.HandleFunc("POST /login", h.loginSubmit) mux.HandleFunc("POST /logout", h.logout) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) // Authenticated (any role) mux.HandleFunc("GET /", h.requireAuth(h.listBooks)) mux.HandleFunc("GET /book/{id}", h.requireAuth(h.bookDetails)) mux.HandleFunc("GET /download/{id}", h.requireAuth(h.downloadBook)) mux.HandleFunc("GET /cover/{id}", h.requireAuth(h.bookCover)) // Uploader and above mux.HandleFunc("GET /upload", h.requireRole(users.RoleUploader, h.uploadPage)) mux.HandleFunc("POST /upload", h.requireRole(users.RoleUploader, h.uploadSubmit)) } func (h *Handler) listBooks(w http.ResponseWriter, r *http.Request) { books, err := h.lib.ListBooks() if err != nil { http.Error(w, "failed to load books", http.StatusInternalServerError) return } sess := sessionFrom(r) canUpload := sess != nil && sess.Role.AtLeast(users.RoleUploader) render(w, r.Context(), views.IndexPage(books, canUpload)) } func (h *Handler) bookDetails(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { http.NotFound(w, r) return } book, err := h.lib.FindBook(id) if err != nil { http.Error(w, "failed to load book", http.StatusInternalServerError) return } if book == nil { http.NotFound(w, r) return } render(w, r.Context(), views.BookPage(*book)) } func (h *Handler) downloadBook(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { http.NotFound(w, r) return } book, err := h.lib.FindBook(id) if err != nil { http.Error(w, "failed to load book", http.StatusInternalServerError) return } if book == nil { http.NotFound(w, r) return } cleanBase := filepath.Clean(h.lib.BooksDir()) cleanPath := filepath.Clean(book.Path) if !strings.HasPrefix(cleanPath, cleanBase+string(filepath.Separator)) && cleanPath != cleanBase { http.Error(w, "invalid file path", http.StatusBadRequest) return } w.Header().Set("Content-Disposition", "attachment; filename=\""+book.Filename+"\"") http.ServeFile(w, r, cleanPath) } func (h *Handler) bookCover(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { http.NotFound(w, r) return } data, contentType, err := h.lib.CoverBytes(id) if err != nil { http.Error(w, "failed to load cover", http.StatusInternalServerError) return } if len(data) == 0 { http.NotFound(w, r) return } if contentType == "" { contentType = "application/octet-stream" } w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "public, max-age=3600") _, _ = w.Write(data) } func render(w http.ResponseWriter, ctx context.Context, c templ.Component) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := c.Render(ctx, w); err != nil && !errors.Is(err, context.Canceled) { http.Error(w, "render failed", http.StatusInternalServerError) } } // --- Auth handlers --- func (h *Handler) loginPage(w http.ResponseWriter, r *http.Request) { render(w, r.Context(), views.LoginPage("")) } func (h *Handler) loginSubmit(w http.ResponseWriter, r *http.Request) { username := r.FormValue("username") password := r.FormValue("password") user, err := h.store.Authenticate(username, password) if err != nil { http.Error(w, "interner Fehler", http.StatusInternalServerError) return } if user == nil { render(w, r.Context(), views.LoginPage("Benutzername oder Passwort falsch.")) return } sess, err := h.store.CreateSession(user) if err != nil { http.Error(w, "Session konnte nicht erstellt werden", http.StatusInternalServerError) return } http.SetCookie(w, &http.Cookie{ Name: "session", Value: sess.Token, Path: "/", Expires: sess.ExpiresAt, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) http.Redirect(w, r, "/", http.StatusSeeOther) } func (h *Handler) logout(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session") if err == nil { _ = h.store.DeleteSession(cookie.Value) } http.SetCookie(w, &http.Cookie{ Name: "session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, }) http.Redirect(w, r, "/login", http.StatusSeeOther) } // --- Upload handlers --- func (h *Handler) uploadPage(w http.ResponseWriter, r *http.Request) { render(w, r.Context(), views.UploadPage("")) } func (h *Handler) uploadSubmit(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) if err := r.ParseMultipartForm(32 << 20); err != nil { render(w, r.Context(), views.UploadPage("Datei zu groß oder ungültige Anfrage.")) return } file, header, err := r.FormFile("book") if err != nil { render(w, r.Context(), views.UploadPage("Keine Datei ausgewählt.")) return } defer file.Close() ext := strings.ToLower(filepath.Ext(header.Filename)) if ext != ".epub" && ext != ".pdf" { render(w, r.Context(), views.UploadPage("Nur EPUB- und PDF-Dateien erlaubt.")) return } // Sanitize filename: keep only base name, no path components. safeFilename := filepath.Base(header.Filename) destPath := filepath.Join(h.lib.BooksDir(), safeFilename) // Check it still resolves inside BooksDir. cleanBase := filepath.Clean(h.lib.BooksDir()) cleanDest := filepath.Clean(destPath) if !strings.HasPrefix(cleanDest, cleanBase+string(filepath.Separator)) { render(w, r.Context(), views.UploadPage("Ungültiger Dateiname.")) return } out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { if os.IsExist(err) { render(w, r.Context(), views.UploadPage(fmt.Sprintf("Datei '%s' existiert bereits.", safeFilename))) return } log.Printf("upload: create %q failed: %v", destPath, err) http.Error(w, "Datei konnte nicht gespeichert werden", http.StatusInternalServerError) return } defer out.Close() if _, err := io.Copy(out, file); err != nil { log.Printf("upload: write %q failed: %v", destPath, err) _ = os.Remove(destPath) http.Error(w, "Upload fehlgeschlagen", http.StatusInternalServerError) return } http.Redirect(w, r, "/", http.StatusSeeOther) }