admin: add admin panel for user management
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (?, ?, ?)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
29
internal/middleware/admin.go
Normal file
29
internal/middleware/admin.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user