diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..1563920 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,67 @@ +# eBook Library (Go + templ) + +Minimalistic, no-JS eBook library web app for reading files (EPUB/PDF) on a +Tolino e-reader browser. UI text/comments are in German. + +## Build & run + +```bash +go mod tidy # install deps +templ generate # regenerate views/*_templ.go from views/*.templ (required after editing .templ files) +go build ./... # build check +go run ./cmd/server # run dev server on :8080 +``` + +- `templ` CLI is required whenever `views/pages.templ` changes — generated + Go code lives in `views/pages_templ.go` and is checked into the repo, so it + must be regenerated and committed together with template edits. +- No test suite exists yet. +- Config via env vars: `ADDR` (default `:8080`), `BOOKS_DIR` (default `books`), + `USERS_DB` (default `users.db`). + +## Admin CLI + +```bash +go run ./cmd/admin user add [--upload] # Benutzer anlegen +go run ./cmd/admin user list # alle Benutzer +go run ./cmd/admin user delete # löschen +go run ./cmd/admin user set-upload +``` + +## Architecture + +- `cmd/server/main.go` — entrypoint; wires `library.Service`, `users.Store` and + `web.Handler` together and starts the HTTP server. +- `internal/library` — core domain logic, no HTTP dependency (unchanged). +- `internal/users/store.go` — SQLite-backed user store (`modernc.org/sqlite`, + pure Go driver). Tables: `users` (id, username, password_hash bcrypt, + can_upload), `sessions` (token, user_id FK, expires_at unix timestamp). + Sessions TTL = 30 days. Key methods: `Authenticate`, `CreateSession`, + `LookupSession`, `DeleteSession`, `CreateUser`, `SetUpload`, `DeleteUser`, `ListUsers`. +- `cmd/admin/main.go` — CLI binary for user management; reads `USERS_DB` env var. +- `internal/web/handlers.go` — HTTP handlers including `loginPage`, + `loginSubmit`, `logout`, `uploadPage`, `uploadSubmit`. +- `internal/web/middleware.go` — `requireAuth` and `requireUpload` middleware; + session resolved from `"session"` cookie; stored in request context via `sessionKey`. +- `views/pages.templ` — `LoginPage(errMsg)`, `UploadPage(errMsg)` added; + `IndexPage` now takes `canUpload bool`; `Layout` includes logout button. + +## Conventions + +- Path traversal guards matter: `downloadBook` validates the resolved book + path stays within `BooksDir()` before serving; `cleanEPUBPath` rejects + `../`-escaping hrefs inside EPUB zips; `uploadSubmit` validates the + destination path stays within `BooksDir()`. Preserve these checks in any + related changes. +- Cover image reads are capped (`maxCoverBytes = 10 MiB`) to avoid decompression + abuse — keep similar limits when reading zip entries. +- Handlers return `404` for missing/empty `id` and `500` on internal errors; + keep that pattern for new routes. +- Auth: `/login` and `/static/` are the only public routes — everything else + goes through `requireAuth`. Routes needing upload permission use `requireUpload` + (which wraps `requireAuth`). +- Session cookie: `HttpOnly`, `SameSite=Lax`, 30-day expiry; no `Secure` flag + set (intended for LAN use without TLS). +- `modernc.org/sqlite` is a pure-Go SQLite driver (no CGo). `MaxOpenConns(1)` + is set because SQLite doesn't support concurrent writers. +- `users.db` is gitignored; create it at runtime with the admin CLI. diff --git a/.gitignore b/.gitignore index 3bd86e0..3b68b94 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ dist/ # Local env .env books/ +users.db diff --git a/README.md b/README.md index a1cd08f..45c9951 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Minimalistische eBook-Bibliothek für den Tolino-Webbrowser. - Detailseite pro Buch - Download-Link pro Buch (für Tolino) - Schlichtes, kontrastreiches UI ohne JavaScript-Abhängigkeit +- Login-Pflicht (Session-Cookie, 30 Tage) +- Optionaler Buch-Upload für berechtigte Benutzer ## Voraussetzungen @@ -34,13 +36,21 @@ go mod tidy templ generate ``` -3. Server starten: +3. Ersten Benutzer anlegen: + +```bash +go run ./cmd/admin user add +# mit Upload-Recht: +go run ./cmd/admin user add --upload +``` + +4. Server starten: ```bash go run ./cmd/server ``` -4. Browser öffnen: +5. Browser öffnen: - `http://localhost:8080` @@ -51,9 +61,23 @@ Lege deine Dateien in den Ordner `books/`: - `.epub` (bevorzugt) - `.pdf` (optional) +Alternativ können Benutzer mit Upload-Recht Bücher direkt im Browser hochladen. + +## Benutzerverwaltung (CLI) + +```bash +go run ./cmd/admin user list +go run ./cmd/admin user add [--upload] +go run ./cmd/admin user delete +go run ./cmd/admin user set-upload +``` + ## Konfiguration Umgebungsvariablen: -- `ADDR` (Standard `:8080`) -- `BOOKS_DIR` (Standard `books`) +| Variable | Standard | Beschreibung | +|------------|------------|---------------------------------| +| `ADDR` | `:8080` | Listen-Adresse des Servers | +| `BOOKS_DIR`| `books` | Verzeichnis mit Büchern | +| `USERS_DB` | `users.db` | Pfad zur SQLite-Benutzerdatenbank | diff --git a/cmd/admin/main.go b/cmd/admin/main.go new file mode 100644 index 0000000..f995c7b --- /dev/null +++ b/cmd/admin/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "syscall" + + "github.com/arnef/ebooks/internal/users" + "golang.org/x/term" +) + +func main() { + if len(os.Args) < 3 { + usage() + os.Exit(1) + } + + dbPath := getenv("USERS_DB", "users.db") + store, err := users.Open(dbPath) + if err != nil { + fatalf("open db: %v\n", err) + } + defer store.Close() + + resource := os.Args[1] + action := os.Args[2] + + switch resource { + case "user": + handleUser(store, action, os.Args[3:]) + default: + usage() + os.Exit(1) + } +} + +func handleUser(store *users.Store, action string, args []string) { + switch action { + case "add": + if len(args) < 1 { + fatalf("usage: user add [--upload]\n") + } + username := args[0] + canUpload := len(args) > 1 && args[1] == "--upload" + + password, err := readPassword(fmt.Sprintf("Passwort für '%s': ", username)) + if err != nil { + fatalf("passwort lesen: %v\n", err) + } + confirm, err := readPassword("Passwort bestätigen: ") + if err != nil { + fatalf("passwort lesen: %v\n", err) + } + if password != confirm { + fatalf("Passwörter stimmen nicht überein.\n") + } + + u, err := store.CreateUser(username, password, canUpload) + if err != nil { + fatalf("user anlegen: %v\n", err) + } + fmt.Printf("✓ Benutzer '%s' angelegt (upload: %s)\n", u.Username, yesNo(u.CanUpload)) + + case "list": + list, err := store.ListUsers() + if err != nil { + fatalf("user auflisten: %v\n", err) + } + if len(list) == 0 { + fmt.Println("Keine Benutzer vorhanden.") + return + } + fmt.Printf("%-20s %s\n", "BENUTZERNAME", "UPLOAD") + fmt.Println(strings.Repeat("-", 32)) + for _, u := range list { + fmt.Printf("%-20s %s\n", u.Username, yesNo(u.CanUpload)) + } + + case "delete": + if len(args) < 1 { + fatalf("usage: user delete \n") + } + if err := store.DeleteUser(args[0]); err != nil { + fatalf("user löschen: %v\n", err) + } + fmt.Printf("✓ Benutzer '%s' gelöscht.\n", args[0]) + + case "set-upload": + if len(args) < 2 { + fatalf("usage: user set-upload \n") + } + val, err := strconv.ParseBool(args[1]) + if err != nil { + fatalf("ungültiger Wert '%s', erwartet true oder false\n", args[1]) + } + if err := store.SetUpload(args[0], val); err != nil { + fatalf("upload-recht setzen: %v\n", err) + } + fmt.Printf("✓ Upload-Recht für '%s' auf %s gesetzt.\n", args[0], yesNo(val)) + + default: + usage() + os.Exit(1) + } +} + +func readPassword(prompt string) (string, error) { + fmt.Print(prompt) + b, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return "", err + } + return string(b), nil +} + +func yesNo(b bool) string { + if b { + return "ja" + } + return "nein" +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format, args...) + os.Exit(1) +} + +func usage() { + fmt.Fprintln(os.Stderr, `ebooks-admin – Benutzerverwaltung + +Befehle: + user add [--upload] Benutzer anlegen (optional: Upload-Recht) + user list Alle Benutzer auflisten + user delete Benutzer löschen + user set-upload Upload-Recht ändern + +Umgebungsvariablen: + USERS_DB Pfad zur SQLite-Datenbank (Standard: users.db)`) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 8c335ce..045a8fa 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,15 +6,23 @@ import ( "os" "github.com/arnef/ebooks/internal/library" + "github.com/arnef/ebooks/internal/users" "github.com/arnef/ebooks/internal/web" ) func main() { booksDir := getenv("BOOKS_DIR", "books") addr := getenv("ADDR", ":8080") + usersDB := getenv("USERS_DB", "users.db") + + store, err := users.Open(usersDB) + if err != nil { + log.Fatalf("users db: %v", err) + } + defer store.Close() lib := library.New(booksDir) - h := web.NewHandler(lib) + h := web.NewHandler(lib, store) mux := http.NewServeMux() h.RegisterRoutes(mux) diff --git a/go.mod b/go.mod index 0427d84..03ca39c 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,18 @@ module github.com/arnef/ebooks go 1.25.0 require github.com/a-h/templ v0.3.1020 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.56.0 // indirect +) diff --git a/go.sum b/go.sum index 0857b6f..f9943ec 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,28 @@ github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= diff --git a/internal/users/store.go b/internal/users/store.go new file mode 100644 index 0000000..4cb5d0c --- /dev/null +++ b/internal/users/store.go @@ -0,0 +1,243 @@ +package users + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" + + _ "modernc.org/sqlite" + "golang.org/x/crypto/bcrypt" +) + +var ErrNotFound = errors.New("user not found") +var ErrUserExists = errors.New("user already exists") + +type User struct { + ID int64 + Username string + CanUpload bool +} + +type Session struct { + Token string + UserID int64 + Username string + CanUpload bool + ExpiresAt time.Time +} + +type Store struct { + db *sql.DB +} + +func Open(path string) (*Store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) // SQLite doesn't support concurrent writers + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) migrate() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + can_upload INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL + ); + `) + return err +} + +// CreateUser adds a new user. Returns ErrUserExists if the username is taken. +func (s *Store) CreateUser(username, password string, canUpload bool) (*User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + res, err := s.db.Exec( + `INSERT INTO users (username, password_hash, can_upload) VALUES (?, ?, ?)`, + username, string(hash), boolToInt(canUpload), + ) + if err != nil { + if isUnique(err) { + return nil, ErrUserExists + } + return nil, err + } + id, _ := res.LastInsertId() + return &User{ID: id, Username: username, CanUpload: canUpload}, nil +} + +// SetUpload changes the upload permission of an existing user. +func (s *Store) SetUpload(username string, canUpload bool) error { + res, err := s.db.Exec( + `UPDATE users SET can_upload = ? WHERE username = ?`, + boolToInt(canUpload), username, + ) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// DeleteUser removes a user by username. +func (s *Store) DeleteUser(username string) error { + res, err := s.db.Exec(`DELETE FROM users WHERE username = ?`, username) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// ListUsers returns all users ordered by username. +func (s *Store) ListUsers() ([]User, error) { + rows, err := s.db.Query(`SELECT id, username, can_upload FROM users ORDER BY username`) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + var up int + if err := rows.Scan(&u.ID, &u.Username, &up); err != nil { + return nil, err + } + u.CanUpload = up != 0 + users = append(users, u) + } + return users, rows.Err() +} + +// Authenticate verifies credentials and returns the matching user. +func (s *Store) Authenticate(username, password string) (*User, error) { + var u User + var hash string + var up int + err := s.db.QueryRow( + `SELECT id, username, password_hash, can_upload FROM users WHERE username = ?`, + username, + ).Scan(&u.ID, &u.Username, &hash, &up) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { + return nil, nil + } + u.CanUpload = up != 0 + return &u, nil +} + +// CreateSession issues a new session token for a user (TTL: 30 days). +func (s *Store) CreateSession(u *User) (*Session, error) { + token, err := randomToken() + if err != nil { + return nil, err + } + exp := time.Now().Add(30 * 24 * time.Hour) + _, err = s.db.Exec( + `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`, + token, u.ID, exp.Unix(), + ) + if err != nil { + return nil, err + } + return &Session{ + Token: token, + UserID: u.ID, + Username: u.Username, + CanUpload: u.CanUpload, + ExpiresAt: exp, + }, nil +} + +// LookupSession returns the session if valid, nil if not found/expired. +func (s *Store) LookupSession(token string) (*Session, error) { + var sess Session + var up int + var expUnix int64 + err := s.db.QueryRow(` + SELECT s.token, s.user_id, u.username, u.can_upload, s.expires_at + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token = ? + `, token).Scan(&sess.Token, &sess.UserID, &sess.Username, &up, &expUnix) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + sess.ExpiresAt = time.Unix(expUnix, 0) + if time.Now().After(sess.ExpiresAt) { + _, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token) + return nil, nil + } + sess.CanUpload = up != 0 + return &sess, nil +} + +// DeleteSession invalidates a session token (logout). +func (s *Store) DeleteSession(token string) error { + _, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token) + return err +} + +func randomToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("random token: %w", err) + } + return hex.EncodeToString(b), nil +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func isUnique(err error) bool { + return err != nil && (contains(err.Error(), "UNIQUE") || contains(err.Error(), "unique")) +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub)) +} + +func containsStr(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 67bf8d2..5686e5e 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -3,29 +3,46 @@ 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 + lib *library.Service + store *users.Store } -func NewHandler(lib *library.Service) *Handler { - return &Handler{lib: lib} +func NewHandler(lib *library.Service, store *users.Store) *Handler { + return &Handler{lib: lib, store: store} } func (h *Handler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /", h.listBooks) - mux.HandleFunc("GET /book/{id}", h.bookDetails) - mux.HandleFunc("GET /download/{id}", h.downloadBook) - mux.HandleFunc("GET /cover/{id}", h.bookCover) + // 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 + 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)) + + // Upload (authenticated + can_upload) + mux.HandleFunc("GET /upload", h.requireUpload(h.uploadPage)) + mux.HandleFunc("POST /upload", h.requireUpload(h.uploadSubmit)) } func (h *Handler) listBooks(w http.ResponseWriter, r *http.Request) { @@ -34,7 +51,9 @@ func (h *Handler) listBooks(w http.ResponseWriter, r *http.Request) { http.Error(w, "failed to load books", http.StatusInternalServerError) return } - render(w, r.Context(), views.IndexPage(books)) + sess := sessionFrom(r) + canUpload := sess != nil && sess.CanUpload + render(w, r.Context(), views.IndexPage(books, canUpload)) } func (h *Handler) bookDetails(w http.ResponseWriter, r *http.Request) { @@ -116,3 +135,113 @@ func render(w http.ResponseWriter, ctx context.Context, c templ.Component) { 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) +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go new file mode 100644 index 0000000..1c9d374 --- /dev/null +++ b/internal/web/middleware.go @@ -0,0 +1,51 @@ +package web + +import ( + "context" + "net/http" + + "github.com/arnef/ebooks/internal/users" +) + +type contextKey int + +const sessionKey contextKey = iota + +// sessionFrom retrieves the session stored in request context. +func sessionFrom(r *http.Request) *users.Session { + s, _ := r.Context().Value(sessionKey).(*users.Session) + return s +} + +// requireAuth is middleware that redirects unauthenticated requests to /login. +func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sess, err := h.sessionFromRequest(r) + if err != nil || sess == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + ctx := context.WithValue(r.Context(), sessionKey, sess) + next(w, r.WithContext(ctx)) + } +} + +// requireUpload is middleware that returns 403 if the user lacks upload permission. +func (h *Handler) requireUpload(next http.HandlerFunc) http.HandlerFunc { + return h.requireAuth(func(w http.ResponseWriter, r *http.Request) { + sess := sessionFrom(r) + if !sess.CanUpload { + http.Error(w, "Keine Berechtigung", http.StatusForbidden) + return + } + next(w, r) + }) +} + +func (h *Handler) sessionFromRequest(r *http.Request) (*users.Session, error) { + cookie, err := r.Cookie("session") + if err != nil { + return nil, nil + } + return h.store.LookupSession(cookie.Value) +} diff --git a/static/styles.css b/static/styles.css index de99fb2..2ee936f 100644 --- a/static/styles.css +++ b/static/styles.css @@ -208,3 +208,39 @@ code { border: 1px solid #000; padding: 0.1rem 0.3rem; } + +/* --- Auth & Upload --- */ + +.btn-logout { + background: none; + border: 1px solid #000; + cursor: pointer; + padding: 0.3rem 0.6rem; + font-size: 0.9rem; +} + +.login-box { + max-width: 22rem; + margin-top: 3rem; +} + +.login-form, +.upload-form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.login-form input, +.upload-form input[type="file"] { + padding: 0.5rem; + font-size: 1rem; + border: 1px solid #000; +} + +.error { + border: 2px solid #000; + padding: 0.5rem 0.75rem; + margin-bottom: 0.75rem; + font-weight: bold; +} diff --git a/views/pages.templ b/views/pages.templ index 393e42d..9303c20 100644 --- a/views/pages.templ +++ b/views/pages.templ @@ -21,6 +21,9 @@ templ Layout(title string, backHref string) { }

eBook Library

+
+ +
@@ -30,6 +33,57 @@ templ Layout(title string, backHref string) { } +templ LoginLayout(title string) { + + + + + + { title } + + + +
+ { children... } +
+ + +} + +templ LoginPage(errMsg string) { + @LoginLayout("Anmelden") { + + } +} + +templ UploadPage(errMsg string) { + @Layout("Buch hochladen", "/") { +
+

Buch hochladen

+
+ if errMsg != "" { +

{ errMsg }

+ } +
+ + + +
+ } +} + templ BookCover(book library.Book, detail bool) { if book.HasCover { { @@ -40,11 +94,14 @@ templ BookCover(book library.Book, detail bool) { } } -templ IndexPage(books []library.Book) { +templ IndexPage(books []library.Book, canUpload bool) { @Layout("Bibliothek", "") {

Meine Bücher

Titel, Autor und Cover auf einen Blick.

+ if canUpload { + Buch hochladen + }
if len(books) == 0 {

Keine eBooks gefunden. Lege EPUB- oder PDF-Dateien im Ordner books/ ab.

diff --git a/views/pages_templ.go b/views/pages_templ.go index 6f11da6..5c0e4ae 100644 --- a/views/pages_templ.go +++ b/views/pages_templ.go @@ -70,7 +70,7 @@ func Layout(title string, backHref string) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

eBook Library

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

eBook Library

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -86,6 +86,196 @@ func Layout(title string, backHref string) templ.Component { }) } +func LoginLayout(title string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 42, Col: 18} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var4.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func LoginPage(errMsg string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

Anmelden

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if errMsg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 58, Col: 33} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = LoginLayout("Anmelden").Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func UploadPage(errMsg string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

Buch hochladen

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if errMsg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 77, Col: 31} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Buch hochladen", "/").Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + func BookCover(book library.Book, detail bool) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context @@ -102,93 +292,93 @@ func BookCover(book library.Book, detail bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var4 := templ.GetChildren(ctx) - if templ_7745c5c3_Var4 == nil { - templ_7745c5c3_Var4 = templ.NopComponent + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if book.HasCover { - var templ_7745c5c3_Var5 = []any{coverClass(detail)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...) + var templ_7745c5c3_Var13 = []any{coverClass(detail)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var13...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" loading=\"lazy\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var9 = []any{placeholderClass(detail)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) + var templ_7745c5c3_Var17 = []any{placeholderClass(detail)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" aria-hidden=\"true\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.Format)) + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(strings.ToUpper(book.Format)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 38, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 92, Col: 42} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -197,7 +387,7 @@ func BookCover(book library.Book, detail bool) templ.Component { }) } -func IndexPage(books []library.Book) templ.Component { +func IndexPage(books []library.Book, canUpload bool) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -213,12 +403,12 @@ func IndexPage(books []library.Book) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var12 := templ.GetChildren(ctx) - if templ_7745c5c3_Var12 == nil { - templ_7745c5c3_Var12 = templ.NopComponent + templ_7745c5c3_Var20 := templ.GetChildren(ctx) + if templ_7745c5c3_Var20 == nil { + templ_7745c5c3_Var20 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var13 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var21 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -230,35 +420,45 @@ func IndexPage(books []library.Book) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

Meine Bücher

Titel, Autor und Cover auf einen Blick.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

Meine Bücher

Titel, Autor und Cover auf einen Blick.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if canUpload { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Buch hochladen") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(books) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

Keine eBooks gefunden. Lege EPUB- oder PDF-Dateien im Ordner books/ ab.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

Keine eBooks gefunden. Lege EPUB- oder PDF-Dateien im Ordner books/ ab.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } return nil }) - templ_7745c5c3_Err = Layout("Bibliothek", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var13), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Layout("Bibliothek", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var21), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -341,12 +541,12 @@ func BookPage(book library.Book) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var18 := templ.GetChildren(ctx) - if templ_7745c5c3_Var18 == nil { - templ_7745c5c3_Var18 = templ.NopComponent + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var19 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var27 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -358,7 +558,7 @@ func BookPage(book library.Book) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -366,78 +566,78 @@ func BookPage(book library.Book) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 79, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 136, Col: 24} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 80, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 137, Col: 51} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

Datei
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "

Datei
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.Filename) + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(book.Filename) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 84, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 141, Col: 31} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
Format
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
Format
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(book.Format) + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(book.Format) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 88, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 145, Col: 29} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "

Auf Tolino herunterladen

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">Auf Tolino herunterladen

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = Layout(book.Title, "/").Render(templ.WithChildren(ctx, templ_7745c5c3_Var19), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Layout(book.Title, "/").Render(templ.WithChildren(ctx, templ_7745c5c3_Var27), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }