Files
sonobarr/src/sonobarr_app/web/admin.py
Beda Schmid 22ea024b94 Harden startup and refactor web/API logic
### Fixed
- Improves OpenAI parsing for fenced code blocks.
- Adds clearer admin actions with separated handlers for user and request management.

### Security
- Runs the application as a non-root user by default and tightens ownership of writable configuration paths.
2025-10-13 14:30:36 -03:00

189 lines
5.9 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from functools import wraps
from flask import Blueprint, abort, current_app, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from ..extensions import db
from ..models import User, ArtistRequest
bp = Blueprint("admin", __name__, url_prefix="/admin")
def admin_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
abort(403)
return view(*args, **kwargs)
return wrapped
def _create_user_from_form(form):
username = (form.get("username") or "").strip()
password = form.get("password") or ""
confirm_password = (form.get("confirm_password") or "").strip()
display_name = (form.get("display_name") or "").strip()
avatar_url = (form.get("avatar_url") or "").strip()
is_admin = form.get("is_admin") == "on"
if not username or not password:
flash("Username and password are required.", "danger")
return
if password != confirm_password:
flash("Password confirmation does not match.", "danger")
return
if User.query.filter_by(username=username).first():
flash("Username already exists.", "danger")
return
user = User(
username=username,
display_name=display_name or None,
avatar_url=avatar_url or None,
is_admin=is_admin,
)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash(f"User '{username}' created.", "success")
def _delete_user_from_form(form):
try:
user_id = int(form.get("user_id", "0"))
except ValueError:
flash("Invalid user id.", "danger")
return
user = User.query.get(user_id)
if not user:
flash("User not found.", "danger")
return
if user.id == current_user.id:
flash("You cannot delete your own account.", "warning")
return
if user.is_admin and User.query.filter_by(is_admin=True).count() <= 1:
flash("At least one administrator must remain.", "warning")
return
# Delete associated artist requests first
ArtistRequest.query.filter_by(requested_by_id=user_id).delete()
ArtistRequest.query.filter_by(approved_by_id=user_id).delete()
db.session.delete(user)
db.session.commit()
flash(f"User '{user.username}' deleted.", "success")
def _resolve_artist_request(form):
request_id = form.get("request_id")
if not request_id:
flash("Invalid request ID.", "danger")
return None
try:
request_id_int = int(request_id)
except ValueError:
flash("Invalid request ID.", "danger")
return None
artist_request = ArtistRequest.query.get(request_id_int)
if not artist_request:
flash("Artist request not found.", "danger")
return None
if artist_request.status != "pending":
flash("Request has already been processed.", "warning")
return None
return artist_request
def _approve_artist_request(artist_request: ArtistRequest):
data_handler = current_app.extensions.get("data_handler")
if not data_handler:
flash(f"Failed to add '{artist_request.artist_name}' to Lidarr. Request not approved.", "danger")
return
session_key = f"admin_{current_user.id}"
data_handler.ensure_session(session_key, current_user.id, True)
result_status = data_handler.add_artists(session_key, artist_request.artist_name)
if result_status != "Added":
flash(f"Failed to add '{artist_request.artist_name}' to Lidarr. Request not approved.", "danger")
return
artist_request.status = "approved"
artist_request.approved_by_id = current_user.id
artist_request.approved_at = datetime.now(timezone.utc)
db.session.commit()
approved_artist = {"Name": artist_request.artist_name, "Status": "Added"}
data_handler.socketio.emit("refresh_artist", approved_artist)
flash(f"Request for '{artist_request.artist_name}' approved and added to Lidarr.", "success")
def _reject_artist_request(artist_request: ArtistRequest):
artist_request.status = "rejected"
artist_request.approved_by_id = current_user.id
artist_request.approved_at = datetime.now(timezone.utc)
db.session.commit()
data_handler = current_app.extensions.get("data_handler")
if data_handler:
rejected_artist = {"Name": artist_request.artist_name, "Status": "Rejected"}
data_handler.socketio.emit("refresh_artist", rejected_artist)
flash(f"Request for '{artist_request.artist_name}' rejected.", "success")
@bp.get("/users")
@login_required
@admin_required
def users():
users_list = User.query.order_by(User.username.asc()).all()
return render_template("admin_users.html", users=users_list)
@bp.post("/users")
@login_required
@admin_required
def modify_users():
action = request.form.get("action")
if action == "create":
_create_user_from_form(request.form)
elif action == "delete":
_delete_user_from_form(request.form)
else:
flash("Invalid action.", "danger")
return redirect(url_for("admin.users"))
@bp.get("/artist-requests")
@login_required
@admin_required
def artist_requests():
pending_requests = ArtistRequest.query.filter_by(status="pending").order_by(
ArtistRequest.created_at.desc()
).all()
return render_template("admin_artist_requests.html", requests=pending_requests)
@bp.post("/artist-requests")
@login_required
@admin_required
def modify_artist_requests():
action = request.form.get("action")
artist_request = _resolve_artist_request(request.form)
if not artist_request:
return redirect(url_for("admin.artist_requests"))
if action == "approve":
_approve_artist_request(artist_request)
elif action == "reject":
_reject_artist_request(artist_request)
else:
flash("Invalid action.", "danger")
return redirect(url_for("admin.artist_requests"))