From 77f0daca26aec0f895e4c370ee3ea0c69d6d2402 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 22 Apr 2026 21:16:26 +0200 Subject: [PATCH] admin: add admin panel for user management --- api/admin/handler.go | 175 ++++++++++++++++++++++++++ internal/db/db.go | 6 +- internal/db/querier.go | 1 + internal/db/queries.sql | 3 + internal/db/queries.sql.go | 38 +++++- internal/middleware/admin.go | 29 +++++ internal/server/routes.go | 17 +++ sqlc.yaml | 4 +- web/shared/admin/admin.go | 32 +++++ web/shared/layout/layout.templ | 13 +- web/templates/admin.templ | 221 +++++++++++++++++++++++++++++++++ 11 files changed, 530 insertions(+), 9 deletions(-) create mode 100644 api/admin/handler.go create mode 100644 internal/middleware/admin.go create mode 100644 web/shared/admin/admin.go create mode 100644 web/templates/admin.templ diff --git a/api/admin/handler.go b/api/admin/handler.go new file mode 100644 index 0000000..9ae4265 --- /dev/null +++ b/api/admin/handler.go @@ -0,0 +1,175 @@ +package admin + +import ( + "database/sql" + "html" + "log" + "net/http" + + "golang.org/x/crypto/bcrypt" + + "mal/api/auth" + "mal/internal/db" + "mal/internal/middleware" + "mal/web/templates" +) + +type Handler struct { + db database.Querier + authService *auth.Service +} + +func NewHandler(db database.Querier, authService *auth.Service) *Handler { + return &Handler{db: db, authService: authService} +} + +func (h *Handler) HandleAdminPage(w http.ResponseWriter, r *http.Request) { + users, err := h.db.ListUsers(r.Context()) + if err != nil { + log.Printf("list users error: %v", err) + http.Error(w, "Failed to load users", http.StatusInternalServerError) + return + } + + if err := templates.AdminPage(users).Render(r.Context(), w); err != nil { + log.Printf("render error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func (h *Handler) HandleImpersonateUser(w http.ResponseWriter, r *http.Request) { + userID := r.URL.Path[len("/admin/users/"):] + if userID == "" { + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + + targetUser, err := h.db.GetUser(r.Context(), userID) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "User not found", http.StatusNotFound) + return + } + log.Printf("get user error: %v", err) + http.Error(w, "Failed to load user", http.StatusInternalServerError) + return + } + + if err := templates.AdminImpersonatePage(targetUser).Render(r.Context(), w); err != nil { + log.Printf("render error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func (h *Handler) HandleUserWatchlist(w http.ResponseWriter, r *http.Request) { + userID := r.URL.Path[len("/admin/users/"):] + userID = userID[:len(userID)-len("/watchlist")] + + entries, err := h.db.GetUserWatchList(r.Context(), userID) + if err != nil { + log.Printf("get user watchlist error: %v", err) + http.Error(w, "Failed to load watchlist", http.StatusInternalServerError) + return + } + + if err := templates.AdminUserWatchlist(entries).Render(r.Context(), w); err != nil { + log.Printf("render error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func (h *Handler) HandleUserContinueWatching(w http.ResponseWriter, r *http.Request) { + userID := r.URL.Path[len("/admin/users/"):] + userID = userID[:len(userID)-len("/continue-watching")] + + entries, err := h.db.GetContinueWatchingEntries(r.Context(), userID) + if err != nil { + log.Printf("get continue watching error: %v", err) + http.Error(w, "Failed to load continue watching", http.StatusInternalServerError) + return + } + + if err := templates.AdminUserContinueWatching(entries).Render(r.Context(), w); err != nil { + log.Printf("render error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +func (h *Handler) HandleAddUserForm(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + + if username == "" || password == "" { + writeInlineError(w, "Username and password are required") + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + log.Printf("bcrypt error: %v", err) + writeInlineError(w, "Failed to create user") + return + } + + _, err = h.db.CreateUser(r.Context(), database.CreateUserParams{ + ID: generateUserID(), + Username: username, + PasswordHash: string(hash), + }) + if err != nil { + log.Printf("create user error: %v", err) + writeInlineError(w, "Failed to create user (may already exist)") + return + } + + // Return success - reload the users list + users, err := h.db.ListUsers(r.Context()) + if err != nil { + log.Printf("list users error: %v", err) + writeInlineError(w, "User created but failed to refresh list") + return + } + + if err := templates.AdminUsersList(users).Render(r.Context(), w); err != nil { + log.Printf("render error: %v", err) + writeInlineError(w, "User created but failed to render list") + } +} + +func writeInlineError(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`

` + html.EscapeString(message) + `

`)) +} + +func generateUserID() string { + // Simple UUID-like generation - in production use proper UUID + b := make([]byte, 16) + for i := range b { + b[i] = byte('a' + (i % 26)) + } + return string(b) +} + +const bcryptCost = 12 + +func GetImpersonatedUserID(r *http.Request) string { + // Check for impersonation parameter + impersonateID := r.URL.Query().Get("as_user") + if impersonateID == "" { + return "" + } + + // Verify the current user is admin + user, ok := r.Context().Value(middleware.UserContextKey).(*database.User) + if !ok || user == nil || !middleware.IsAdmin(user) { + return "" + } + + return impersonateID +} diff --git a/internal/db/db.go b/internal/db/db.go index 37a578c..85d4b8c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -10,10 +10,10 @@ import ( ) type DBTX interface { - ExecContext(context.Context, string, ...any) (sql.Result, error) + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...any) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...any) *sql.Row + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row } func New(db DBTX) *Queries { diff --git a/internal/db/querier.go b/internal/db/querier.go index 05a0737..fd56ec3 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -33,6 +33,7 @@ type Querier interface { GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) + ListUsers(ctx context.Context) ([]User, error) MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error MarkRelationsSynced(ctx context.Context, id int64) error SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error diff --git a/internal/db/queries.sql b/internal/db/queries.sql index 6de6f3a..0ce3de5 100644 --- a/internal/db/queries.sql +++ b/internal/db/queries.sql @@ -4,6 +4,9 @@ SELECT * FROM user WHERE id = ? LIMIT 1; -- name: GetUserByUsername :one SELECT * FROM user WHERE username = ? LIMIT 1; +-- name: ListUsers :many +SELECT * FROM user ORDER BY created_at DESC; + -- name: CreateUser :one INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?) diff --git a/internal/db/queries.sql.go b/internal/db/queries.sql.go index f7b089e..4645bba 100644 --- a/internal/db/queries.sql.go +++ b/internal/db/queries.sql.go @@ -688,6 +688,38 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat return items, nil } +const listUsers = `-- name: ListUsers :many +SELECT id, username, password_hash, created_at FROM user ORDER BY created_at DESC +` + +func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { + rows, err := q.db.QueryContext(ctx, listUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const markAnimeFetchRetryFailed = `-- name: MarkAnimeFetchRetryFailed :exec UPDATE anime_fetch_retry SET attempts = attempts + 1, @@ -698,9 +730,9 @@ WHERE anime_id = ? ` type MarkAnimeFetchRetryFailedParams struct { - Datetime string `json:"datetime"` - LastError string `json:"last_error"` - AnimeID int64 `json:"anime_id"` + Datetime interface{} `json:"datetime"` + LastError string `json:"last_error"` + AnimeID int64 `json:"anime_id"` } func (q *Queries) MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error { diff --git a/internal/middleware/admin.go b/internal/middleware/admin.go new file mode 100644 index 0000000..8fb9b4d --- /dev/null +++ b/internal/middleware/admin.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "net/http" + + "mal/internal/db" + "mal/web/shared/admin" +) + +func IsAdmin(user *database.User) bool { + return admin.IsAdmin(user) +} + +func RequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(UserContextKey).(*database.User) + if !ok || user == nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + if !admin.IsAdmin(user) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 28bf2c3..0c135f1 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "mal/api/admin" "mal/api/anime" "mal/api/auth" "mal/api/playback" @@ -52,6 +53,7 @@ func NewRouter(cfg Config) http.Handler { animeHandler := anime.NewHandler(cfg.JikanClient, cfg.DB) playbackSvc := playback.NewService(cfg.DB, cfg.SQLDB, playback.Config{ProxyTokenSecret: cfg.PlaybackProxySecret}) playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient) + adminHandler := admin.NewHandler(cfg.DB, cfg.AuthService) // Serve static files fs := http.FileServer(http.Dir("./static")) @@ -103,6 +105,21 @@ func NewRouter(cfg Config) http.Handler { mux.HandleFunc("/api/continue-watching/", watchlistHandler.HandleDeleteContinueWatching) mux.HandleFunc("/watchlist", watchlistHandler.HandleGetWatchlist) + // Admin Endpoints (protected by admin middleware in route handlers) + mux.Handle("/admin", middleware.RequireAdmin(http.HandlerFunc(adminHandler.HandleAdminPage))) + mux.Handle("/admin/users", middleware.RequireAdmin(http.HandlerFunc(adminHandler.HandleAddUserForm))) + mux.Handle("/admin/users/", middleware.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/watchlist"): + adminHandler.HandleUserWatchlist(w, r) + case strings.HasSuffix(path, "/continue-watching"): + adminHandler.HandleUserContinueWatching(w, r) + default: + adminHandler.HandleImpersonateUser(w, r) + } + }))) + // Wrap mux with global CSRF origin verification and auth checking, // THEN auth context parsing. protectedHandler := middleware.RequireGlobalAuthWithPolicy(middleware.NewAccessPolicy())(pkgmiddleware.VerifyOrigin(mux)) diff --git a/sqlc.yaml b/sqlc.yaml index 563aac9..7869068 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -1,12 +1,12 @@ version: "2" sql: - engine: "sqlite" - queries: "internal/database/queries.sql" + queries: "internal/db/queries.sql" schema: "migrations/" gen: go: package: "database" - out: "internal/database" + out: "internal/db" emit_json_tags: true emit_prepared_queries: false emit_interface: true diff --git a/web/shared/admin/admin.go b/web/shared/admin/admin.go new file mode 100644 index 0000000..0cf0214 --- /dev/null +++ b/web/shared/admin/admin.go @@ -0,0 +1,32 @@ +package admin + +import ( + "context" + + "mal/internal/db" +) + +type contextKey string + +const userContextKey contextKey = "user" + +const AdminEmail = "mikkelelvers@outlook.com" + +func IsAdmin(user *database.User) bool { + if user == nil { + return false + } + return user.Username == AdminEmail +} + +func GetUser(ctx context.Context) *database.User { + user, ok := ctx.Value(userContextKey).(*database.User) + if !ok { + return nil + } + return user +} + +func IsAdminFromContext(ctx context.Context) bool { + return IsAdmin(GetUser(ctx)) +} diff --git a/web/shared/layout/layout.templ b/web/shared/layout/layout.templ index 14c6f67..bc9cbbf 100644 --- a/web/shared/layout/layout.templ +++ b/web/shared/layout/layout.templ @@ -1,6 +1,9 @@ package layout -import "mal/web/components/icons" +import ( + "mal/web/components/icons" + "mal/web/shared/admin" +) templ Layout(title string, showHeader bool) { @@ -60,6 +63,14 @@ templ Layout(title string, showHeader bool) { > Watchlist + if admin.IsAdminFromContext(ctx) { + + Admin + + }
+

Admin Panel

+ + +
+

Add New User

+
+ + + +
+
+ + +
+

Users

+ @AdminUsersList(users) +
+
+ } +} + +templ AdminUsersList(users []database.User) { +
+ for _, user := range users { +
+
+
{ user.Username }
+
ID: { user.ID }
+
Created: { user.CreatedAt.Format("2006-01-02") }
+
+ +
+ } +
+} + +templ AdminImpersonatePage(user database.User) { + @layout.Layout("mal - admin - user view", true) { +
+
+
+

Viewing User

+

{ user.Username }

+
+ + ← Back to Admin + +
+ + + +
+ Read-only mode: You are viewing this user's data. Changes cannot be made from this view. +
+
+ } +} + +templ AdminUserWatchlist(entries []database.GetUserWatchListRow) { + @layout.Layout("mal - admin - watchlist", true) { +
+
+

User Watchlist

+ + ← Back to Admin + +
+ + if len(entries) == 0 { +
+ No watchlist entries +
+ } else { +
+ for _, entry := range entries { +
+
+ if entry.ImageUrl != "" { + { + } else { +
No image
+ } +
+
{ entry.TitleOriginal }
+
Status: { entry.Status }
+ if entry.CurrentEpisode.Valid { +
Episode: { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }
+ } +
+ } +
+ } + +
+ Read-only mode: You are viewing this user's data. Changes cannot be made from this view. +
+
+ } +} + +templ AdminUserContinueWatching(entries []database.GetContinueWatchingEntriesRow) { + @layout.Layout("mal - admin - continue watching", true) { +
+
+

Continue Watching

+ + ← Back to Admin + +
+ + if len(entries) == 0 { +
+ No continue watching entries +
+ } else { +
+ for _, entry := range entries { +
+
+ if entry.ImageUrl != "" { + { + } else { +
No image
+ } +
+
{ entry.TitleOriginal }
+ if entry.CurrentEpisode.Valid { +
Episode: { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }
+ } + if entry.CurrentTimeSeconds > 0 { +
Time: { fmt.Sprintf("%.0fs", entry.CurrentTimeSeconds) }
+ } +
+ } +
+ } + +
+ Read-only mode: You are viewing this user's data. Changes cannot be made from this view. +
+
+ } +}