feat(security): library visibility enforced at the source; per-library series; annotation ownership; admin user creation
The family-lens hardening: library_visibility was only enforced where file bytes flowed (download/reader/OPDS) — metadata surfaces leaked hidden libraries to any authenticated account, and registration was open by default with no admin-side user creation. - Series are grouped PER LIBRARY now (same name in two libraries = two entries: manga adaptations / LN splits no longer merge); /api/series aggregates only the requester's VISIBLE libraries and /api/series/books REQUIRES a visible library_id (breaking contract; web + app clients updated in the same change). - /api/media-items, item detail, and the reader content endpoints (chapters/panels/outline/THUMBNAILS — page images leaked unguarded next to a gated GetPage) all enforce the visible set; resolveLibrary no longer honors a hand-typed library_id outside the visible set. - Annotation/note/bookmark mutations (update/delete/tombstone, sync LWW paths incl. conflict resolution) carry ownership predicates: another account's bare row id is a 404, not a silent delete. - Admin user creation: POST /api/auth/users (AdminMiddleware) + a Create User card in the admin Users UI — accounts stay creatable with self-service registration OFF, which is now the schema default (fresh installs; the first-admin bootstrap exception is unchanged). - Tests: series contract re-pinned (per-library, visibility 403s), new ownership + admin-create integration suites, OPDS all=1 test now passes an explicit page size (shared-DB growth made the fresh books fall off page 1), device-cap helper switched to the admin endpoint.
This commit is contained in:
1 parent
97fbdf19fb
commit
431fec05ad
60 files changed
+1573
-691
No files matched your search
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -686,6 +687,79 @@ func (h *AuthHandler) ListUsers(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, users)
|
||||
}
|
||||
|
||||
// AdminCreateUser handles POST /api/auth/users (AdminMiddleware-gated).
|
||||
// The admin's counterpart to public registration: creating an account for
|
||||
// someone else must work even when self-service registration is disabled
|
||||
// (the family/self-hosted default). The requester's admin role comes from
|
||||
// the route group, and the new account NEVER auto-logins — no tokens are
|
||||
// issued here, unlike Register.
|
||||
func (h *AuthHandler) AdminCreateUser(c *echo.Context) error {
|
||||
var req RegisterRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if req.Username == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "username cannot be empty or whitespace"})
|
||||
}
|
||||
|
||||
role := strings.ToLower(req.Role)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if role != "user" && role != "admin" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid role. must be 'user' or 'admin'"})
|
||||
}
|
||||
|
||||
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "email already exists"})
|
||||
}
|
||||
if _, err := h.db.GetUserByUsername(c.Request().Context(), req.Username); err == nil {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "username already exists"})
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
||||
}
|
||||
|
||||
user, err := h.db.CreateUser(c.Request().Context(), database.CreateUserParams{
|
||||
Email: req.Email,
|
||||
Username: req.Username,
|
||||
PasswordHash: string(hashedPassword),
|
||||
FirstName: pgtype.Text{String: req.FirstName, Valid: req.FirstName != ""},
|
||||
LastName: pgtype.Text{String: req.LastName, Valid: req.LastName != ""},
|
||||
Theme: pgtype.Text{String: "tokyo-night", Valid: true},
|
||||
Role: role,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create default collections"})
|
||||
}
|
||||
|
||||
// The admin UI posts this via HTMX: refresh the page so the new user
|
||||
// appears in the table, and confirm inline.
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
c.Response().Header().Set("HX-Refresh", "true")
|
||||
return c.HTML(http.StatusCreated, `<div class="text-green-500">User "`+template.HTMLEscapeString(user.Username)+`" created.</div>`)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||
"id": uuid.UUID(user.ID.Bytes).String(),
|
||||
"email": user.Email,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
"created_at": user.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// normalizePath cleans and normalizes folder paths for consistent storage and comparison
|
||||
func normalizePath(path string) string {
|
||||
fmt.Printf("normalizePath input: '%s'\n", path)
|
||||
|
||||
Reference in new issue
Block a user