Files
ebooks/internal/web/handlers.go
T
arnefandCopilot be3dfaeb47 Allow selecting and uploading multiple EPUB files at once
- views/pages.templ: upload input now has "multiple" attribute; page
  title/heading updated to plural ("Bücher hochladen"); templ regenerated
- internal/web/handlers.go: uploadSubmit now reads all files from
  r.MultipartForm.File["book"] instead of a single r.FormFile, saving
  each via a new saveUpload helper; partial failures are reported per
  filename while successfully saved files are kept

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-15 14:23:05 +02:00

281 lines
7.2 KiB
Go

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 ""
}