admin: add admin panel for user management

This commit is contained in:
2026-04-22 21:16:26 +02:00
parent 7e15380638
commit 77f0daca26
11 changed files with 530 additions and 9 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 (?, ?, ?)

View File

@@ -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 {

View File

@@ -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)
})
}

View File

@@ -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))