package web import ( "context" "errors" "fmt" "io" "log" "mime/multipart" "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 } var headers []*multipart.FileHeader if r.MultipartForm != nil { headers = r.MultipartForm.File["book"] } if len(headers) == 0 { render(w, r.Context(), views.UploadPage("Keine Datei ausgewählt.")) return } var errs []string saved := 0 for _, header := range headers { if msg := h.saveUpload(header); msg != "" { errs = append(errs, fmt.Sprintf("%s: %s", header.Filename, msg)) } else { saved++ } } if len(errs) > 0 { msg := strings.Join(errs, " ") if saved > 0 { msg = fmt.Sprintf("%d von %d Dateien hochgeladen. %s", saved, len(headers), msg) } render(w, r.Context(), views.UploadPage(msg)) return } http.Redirect(w, r, "/", http.StatusSeeOther) } // saveUpload validates and persists a single uploaded file. It returns a // user-facing German error message, or "" on success. func (h *Handler) saveUpload(header *multipart.FileHeader) string { ext := strings.ToLower(filepath.Ext(header.Filename)) if ext != ".epub" { return "Nur EPUB-Dateien erlaubt." } file, err := header.Open() if err != nil { log.Printf("upload: open %q failed: %v", header.Filename, err) return "Datei konnte nicht gelesen werden." } defer file.Close() // 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)) { return "Ungültiger Dateiname." } out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { if os.IsExist(err) { return fmt.Sprintf("Datei '%s' existiert bereits.", safeFilename) } log.Printf("upload: create %q failed: %v", destPath, err) return "Datei konnte nicht gespeichert werden." } defer out.Close() if _, err := io.Copy(out, file); err != nil { log.Printf("upload: write %q failed: %v", destPath, err) _ = os.Remove(destPath) return "Upload fehlgeschlagen." } return "" }