Files
ebooks/internal/web/handlers.go
T
arnefandCopilot 817763887a refactor: replace can_upload boolean with role-based access control
Introduces a three-tier role hierarchy: reader < uploader < admin.

- internal/users/role.go: Role type, roleLevel map, AtLeast(min),
  ParseRole() — new roles added by inserting into roleLevel only
- internal/users/store.go: versioned migrations via _schema_version table;
  v2 migration adds 'role' column and migrates existing can_upload data;
  SetRole() replaces SetUpload(); Session carries Role instead of CanUpload
- internal/web/middleware.go: generic requireRole(minRole) middleware
  replaces the ad-hoc requireUpload
- internal/web/handlers.go: upload routes use requireRole(RoleUploader);
  listBooks derives canUpload from sess.Role.AtLeast(RoleUploader)
- cmd/admin/main.go: user add --role <reader|uploader|admin>,
  user set-role replaces user set-upload
- README, copilot-instructions updated

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-11 19:43:39 +02:00

248 lines
6.5 KiB
Go

package web
import (
"context"
"errors"
"fmt"
"io"
"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
}
http.Error(w, "Datei konnte nicht gespeichert werden", http.StatusInternalServerError)
return
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil {
_ = os.Remove(destPath)
http.Error(w, "Upload fehlgeschlagen", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}