Files
ebooks/internal/web/handlers.go
T
arnefandCopilot a51578cbf1
Docker Image bauen und veröffentlichen / docker (push) Successful in 2m38s
Drop PDF support, EPUB only
- internal/library: ListBooks only scans .epub files, dropping the
  pdf-specific metadata branch (epub metadata reading is now
  unconditional)
- internal/web: uploadSubmit rejects non-.epub uploads with updated
  error message
- views: upload form/file input and empty-library hint now reference
  only EPUB; templ regenerated
- README/copilot-instructions updated to reflect EPUB-only scope

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-15 11:50:27 +02:00

251 lines
6.6 KiB
Go

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" {
render(w, r.Context(), views.UploadPage("Nur EPUB-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)
}