add auth support

This commit is contained in:
Mateusz Gruszczyński
2026-05-06 08:38:07 +02:00
parent aea3c92830
commit dc1cac4e6f
20 changed files with 1185 additions and 220 deletions

View File

@@ -13,9 +13,10 @@ import socket
import json
import psutil
import xml.etree.ElementTree as ET
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, request, abort
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS
from ..db import default_user_id, connect, utcnow
from ..db import connect, utcnow
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write
from ..services import preferences, rtorrent, torrent_stats
from ..services.torrent_cache import torrent_cache
from ..services.torrent_summary import cached_summary
@@ -27,6 +28,77 @@ bp = Blueprint("api", __name__, url_prefix="/api")
MOVE_BULK_MAX_HASHES = 100
@bp.post("/auth/login")
def auth_login():
# Note: Auth API is hidden when optional authentication is disabled.
if not auth_enabled():
abort(404)
data = request.get_json(silent=True) or {}
user = login_user(str(data.get("username") or ""), str(data.get("password") or ""))
if not user:
return jsonify({"ok": False, "error": "Invalid username or password"}), 401
return ok({"user": user, "auth_enabled": auth_enabled()})
@bp.get("/auth/me")
def auth_me():
if not auth_enabled():
abort(404)
return ok({"user": current_user(), "auth_enabled": auth_enabled()})
@bp.post("/auth/logout")
def auth_logout():
if not auth_enabled():
abort(404)
logout_user()
return ok()
@bp.get("/auth/users")
def auth_users_list():
if not auth_enabled():
abort(404)
return ok({"users": list_users()})
@bp.post("/auth/users")
def auth_users_create():
if not auth_enabled():
abort(404)
try:
return ok({"user": save_user(request.get_json(silent=True) or {})})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.put("/auth/users/<int:user_id>")
def auth_users_update(user_id: int):
if not auth_enabled():
abort(404)
try:
return ok({"user": save_user(request.get_json(silent=True) or {}, user_id)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
@bp.delete("/auth/users/<int:user_id>")
def auth_users_delete(user_id: int):
if not auth_enabled():
abort(404)
try:
delete_user(user_id)
return ok({"users": list_users()})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
def _job_profile_id(job_id: str) -> int | None:
with connect() as conn:
row = conn.execute("SELECT profile_id FROM jobs WHERE id=?", (job_id,)).fetchone()
return int(row.get("profile_id") or 0) if row else None
def ok(payload=None):
data = {"ok": True}
if payload:
@@ -312,7 +384,7 @@ def _chunk_hashes(hashes: list[str], size: int = MOVE_BULK_MAX_HASHES) -> list[l
def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict]:
# Note: Jedna wspolna funkcja dzieli duze operacje move/remove na male, uporzadkowane party bez ruszania pozostalych akcji.
# Note: One shared helper splits large move/remove operations into small ordered parts without changing other actions.
base_payload = enrich_bulk_payload(profile, action_name, data)
hashes = base_payload.get("hashes") or []
chunks = _chunk_hashes(hashes)
@@ -342,12 +414,12 @@ def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict
def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]:
# Note: Zachowuje stary publiczny helper dla move, ale korzysta z tej samej logiki partycji.
# Note: Keep the old public move helper while using the same partitioning logic.
return enqueue_bulk_parts(profile, "move", data)
def enqueue_remove_bulk_parts(profile: dict, data: dict) -> list[dict]:
# Note: Remove/rm dostaje identyczne dzielenie na party jak move, co zmniejsza load na rTorrent.
# Note: Remove/rm uses the same partitioning as move, which lowers rTorrent load.
return enqueue_bulk_parts(profile, "remove", data)
@@ -413,6 +485,8 @@ def torrents():
@bp.get("/torrent-stats")
def torrent_stats_get():
profile = preferences.active_profile()
if not profile:
return ok({"stats": {}, "error": "No profile"})
force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"}
try:
# Note: Heavy file metadata is served from a 15-minute DB cache unless the user explicitly refreshes it.
@@ -640,7 +714,7 @@ def jobs_list():
@bp.post("/jobs/clear")
def jobs_clear():
if str(request.args.get("force") or "").lower() in {"1", "true", "yes"}:
# Awaryjne czyszczenie: endpoint zachowuje standardowe działanie, a force=1 uruchamia tryb ratunkowy.
# Note: Emergency cleanup keeps the endpoint behavior unchanged, while force=1 enables rescue mode.
deleted = emergency_clear_jobs()
return ok({"deleted": deleted, "emergency": True})
deleted = clear_jobs()
@@ -685,6 +759,7 @@ def cleanup_all():
@bp.post("/jobs/<job_id>/cancel")
def jobs_cancel(job_id: str):
require_profile_write(_job_profile_id(job_id))
if not cancel_job(job_id):
return jsonify({"ok": False, "error": "Only unfinished jobs can be cancelled"}), 400
return ok({"emergency": True})
@@ -692,6 +767,7 @@ def jobs_cancel(job_id: str):
@bp.post("/jobs/<job_id>/retry")
def jobs_retry(job_id: str):
require_profile_write(_job_profile_id(job_id))
if not retry_job(job_id):
return jsonify({"ok": False, "error": "Only failed or cancelled jobs can be retried"}), 400
return ok()
@@ -910,7 +986,7 @@ def smart_queue_check():
return ok({'result': {'ok': False, 'error': 'No profile'}})
try:
result = smart_queue.check(profile, force=True)
# Note: Ręczny check zwraca od razu świeży snapshot, żeby UI pokazało realną liczbę Downloading po akcji.
# Note: Manual check immediately returns a fresh snapshot so the UI shows the real Downloading count after the action.
diff = torrent_cache.refresh(profile)
rows = torrent_cache.snapshot(profile['id'])
return ok({'result': result, 'torrent_patch': {**diff, 'summary': cached_summary(profile['id'], rows, force=True)}})

View File

@@ -1,11 +1,34 @@
from __future__ import annotations
from flask import Blueprint, render_template, jsonify, Response
from flask import Blueprint, render_template, jsonify, Response, request, redirect, url_for, abort
from ..services.preferences import get_preferences, list_profiles, active_profile, BOOTSTRAP_THEMES, FONT_FAMILIES, bootstrap_css_url
from ..services import auth
bp = Blueprint("main", __name__)
@bp.route("/login", methods=["GET", "POST"])
def login():
# Note: When optional authentication is disabled, /login is intentionally unavailable.
if not auth.enabled():
abort(404)
error = ""
if request.method == "POST":
user = auth.login_user(request.form.get("username", ""), request.form.get("password", ""))
if user:
return redirect(request.args.get("next") or url_for("main.index"))
error = "Invalid username or password"
return render_template("login.html", error=error)
@bp.get("/logout")
def logout():
auth.logout_user()
if not auth.enabled():
return redirect(url_for("main.index"))
return redirect(url_for("main.login"))
@bp.get("/")
def index():
prefs = get_preferences()
@@ -17,6 +40,8 @@ def index():
bootstrap_themes=BOOTSTRAP_THEMES,
font_families=FONT_FAMILIES,
bootstrap_css_url=bootstrap_css_url((prefs or {}).get("bootstrap_theme")),
auth_enabled=auth.enabled(),
current_user=auth.current_user(),
)
@@ -80,7 +105,13 @@ def openapi():
"/api/traffic/history": {"get": {"summary": "Transfer history for charts", "parameters": [{"name": "range", "in": "query", "schema": {"type": "string", "enum": ["15m", "1h", "3h", "6h", "24h", "7d", "30d", "90d"]}}], "responses": {"200": {"description": "Aggregated traffic history"}}}}
}
paths.update({
"/api/auth/login": {"post": {"summary": "Log in with username and password when authentication is enabled", "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"username": {"type": "string"}, "password": {"type": "string", "format": "password"}}, "required": ["username", "password"]}}}}, "responses": {"200": {"description": "Logged in"}, "401": {"description": "Invalid credentials"}, "404": {"description": "Authentication disabled"}}}},
"/api/auth/me": {"get": {"summary": "Return current authenticated user", "responses": {"200": {"description": "Current user"}, "404": {"description": "Authentication disabled"}}}},
"/api/auth/logout": {"post": {"summary": "Log out current user", "responses": {"200": {"description": "Logged out"}, "404": {"description": "Authentication disabled"}}}},
"/api/auth/users": {"get": {"summary": "List users, admin only", "responses": {"200": {"description": "Users"}, "403": {"description": "Admin only"}}}, "post": {"summary": "Create user, admin only", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthUserInput"}}}}, "responses": {"200": {"description": "User created"}, "403": {"description": "Admin only"}}}},
"/api/auth/users/{user_id}": {"put": {"summary": "Update user, admin only", "parameters": [{"name": "user_id", "in": "path", "required": True, "schema": {"type": "integer"}}], "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AuthUserInput"}}}}, "responses": {"200": {"description": "User updated"}, "403": {"description": "Admin only"}}}, "delete": {"summary": "Delete user, admin only", "parameters": [{"name": "user_id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "User deleted"}, "403": {"description": "Admin only"}}}},
"/api/profiles/{profile_id}": {"delete": {"summary": "Delete rTorrent profile", "parameters": [{"name": "profile_id", "in": "path", "required": True, "schema": {"type": "integer"}}], "responses": {"200": {"description": "Deleted"}}}},
"/api/torrent-stats": {"get": {"summary": "Torrent statistics and cached file metadata", "parameters": [{"name": "force", "in": "query", "schema": {"type": "boolean", "default": False}}], "responses": {"200": {"description": "Torrent statistics"}}}},
"/api/path/default": {"get": {"summary": "Read active rTorrent default download path", "responses": {"200": {"description": "Default path"}}}},
"/api/torrents/{torrent_hash}/files/priority": {"post": {"summary": "Set file priorities", "parameters": [{"name": "torrent_hash", "in": "path", "required": True, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"files": {"type": "array", "items": {"type": "object", "properties": {"index": {"type": "integer"}, "priority": {"type": "integer", "enum": [0, 1, 2]}}}}}}}}}, "responses": {"200": {"description": "Updated priorities"}, "207": {"description": "Partial update"}}}},
"/api/torrents/{torrent_hash}/peers/action": {"post": {"summary": "Run peer action", "parameters": [{"name": "torrent_hash", "in": "path", "required": True, "schema": {"type": "string"}}], "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {"peer_index": {"type": "integer"}, "action": {"type": "string", "enum": ["disconnect", "kick", "snub", "unsnub", "ban"]}}}}}}, "responses": {"200": {"description": "Peer action result"}}}},
@@ -98,6 +129,17 @@ def openapi():
"properties": {"ok": {"type": "boolean"}},
"required": ["ok"],
},
"AuthUserInput": {
"type": "object",
"properties": {
"username": {"type": "string"},
"password": {"type": "string", "format": "password", "description": "Optional on update"},
"role": {"type": "string", "enum": ["admin", "user"]},
"is_active": {"type": "boolean"},
"permissions": {"type": "array", "items": {"type": "object", "properties": {"profile_id": {"type": "integer", "description": "0 means all profiles"}, "access_level": {"type": "string", "enum": ["ro", "full"]}}}},
},
"required": ["username"],
},
"Profile": {
"type": "object",
"additionalProperties": True,
@@ -278,4 +320,9 @@ def openapi():
},
})
components.setdefault("securitySchemes", {})["sessionCookie"] = {"type": "apiKey", "in": "cookie", "name": "session"}
for path, methods in paths.items():
if path != "/api/auth/login":
for operation in methods.values():
operation.setdefault("security", [{"sessionCookie": []}])
return jsonify({"openapi": "3.0.3", "info": {"title": "pyTorrent API", "version": "0.2.0"}, "paths": paths, "components": components})