feat: add authentication and book upload

- Session-based login (username/password, 30-day cookie)
- SQLite user store with bcrypt password hashing (modernc.org/sqlite)
- Per-user upload permission (can_upload flag)
- Admin CLI (cmd/admin) for user management:
  user add/list/delete/set-upload
- Upload handler for EPUB/PDF with path-traversal protection
- All routes protected by requireAuth middleware;
  /upload additionally requires requireUpload
- Login/logout UI, upload form, logout button in header
- New env var: USERS_DB (default: users.db, gitignored)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-11 19:11:40 +02:00
co-authored by Copilot
parent a8b31dae8a
commit a66c370de4
13 changed files with 1115 additions and 111 deletions
+149
View File
@@ -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 <username> [--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 <username>\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 <username> <true|false>\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 <name> [--upload] Benutzer anlegen (optional: Upload-Recht)
user list Alle Benutzer auflisten
user delete <name> Benutzer löschen
user set-upload <name> <true|false> Upload-Recht ändern
Umgebungsvariablen:
USERS_DB Pfad zur SQLite-Datenbank (Standard: users.db)`)
}
+9 -1
View File
@@ -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)