From dd0d0cfde6804eec52579c935823ce5108037187 Mon Sep 17 00:00:00 2001 From: Benjamin Hardy Date: Mon, 9 Mar 2026 22:34:27 +0100 Subject: [PATCH] feat: implemented account system --- .env.example | 9 +- Dockerfile | 1 + app.py | 735 ++++++++++++++++++++++++++++++++----------- db.py | 232 ++++++++++++++ firebase-debug.log | 0 requirements.txt | 1 + templates/index.html | 358 ++++++++++++++++++++- templates/login.html | 8 +- 8 files changed, 1152 insertions(+), 192 deletions(-) create mode 100644 db.py create mode 100644 firebase-debug.log diff --git a/.env.example b/.env.example index f5a481a..d998a79 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ -# Set a password to protect the app. Leave empty to disable auth. -PASSWORD= +# Admin account seeded on first run (if no users exist yet). +ADMIN_USERNAME=admin +ADMIN_PASSWORD=changeme + +# Secret key for Flask sessions. Set a fixed value so sessions survive restarts. +# Generate one with: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY= # Host port to expose the app on. PORT=5000 diff --git a/Dockerfile b/Dockerfile index 5d0b711..b532756 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt COPY app.py /app/app.py +COPY db.py /app/db.py COPY utils.py /app/utils.py COPY templates /app/templates COPY static /app/static diff --git a/app.py b/app.py index 2532f98..36d8992 100644 --- a/app.py +++ b/app.py @@ -9,6 +9,7 @@ import uuid import zipfile from pathlib import Path +import db as database from utils import rename_from_metadata, sanitize_filename, cleanup_empty_dirs from flask import ( @@ -25,22 +26,6 @@ from flask import ( app = Flask(__name__) app.secret_key = os.environ.get("SECRET_KEY", os.urandom(32).hex()) -# Auth -APP_PASSWORD = os.environ.get("PASSWORD", "") - - -@app.before_request -def require_login(): - if not APP_PASSWORD: - return - if request.endpoint in ("login", "static", "service_worker", "offline"): - return - if not session.get("authenticated"): - if request.path.startswith("/api/"): - return jsonify({"error": "Unauthorized"}), 401 - return redirect(url_for("login")) - - # Paths DOWNLOADS_DIR = Path(os.environ.get("DOWNLOADS_DIR", "/downloads")) COOKIES_PATH = Path(os.environ.get("COOKIES_PATH", "/config/cookies.txt")) @@ -51,11 +36,49 @@ TEMP_DIR = Path("/tmp/votify") DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True) TEMP_DIR.mkdir(parents=True, exist_ok=True) +# Database +DB_PATH = CONFIG_DIR / "trackpull.db" +database.init_db(DB_PATH) + # In-memory job tracking jobs: dict[str, dict] = {} jobs_lock = threading.Lock() +# --------------------------------------------------------------------------- +# Auth helpers +# --------------------------------------------------------------------------- + +@app.before_request +def require_login(): + public = {"login", "logout", "static", "service_worker", "offline"} + if request.endpoint in public: + return + if not session.get("user_id"): + if request.path.startswith("/api/"): + return jsonify({"error": "Unauthorized"}), 401 + return redirect(url_for("login")) + + +def require_admin(): + if session.get("role") != "admin": + return jsonify({"error": "Forbidden"}), 403 + + +# --------------------------------------------------------------------------- +# Per-user download directory +# --------------------------------------------------------------------------- + +def get_user_downloads_dir(user_id: str) -> Path: + d = DOWNLOADS_DIR / user_id + d.mkdir(parents=True, exist_ok=True) + return d + + +# --------------------------------------------------------------------------- +# Audio helpers +# --------------------------------------------------------------------------- + def snapshot_audio_files(directory: Path) -> set[Path]: extensions = {".m4a", ".ogg", ".opus"} files = set() @@ -64,8 +87,8 @@ def snapshot_audio_files(directory: Path) -> set[Path]: return files -def convert_to_mp3(job_id: str, before: set[Path]): - after = snapshot_audio_files(DOWNLOADS_DIR) +def convert_to_mp3(job_id: str, before: set[Path], user_dir: Path): + after = snapshot_audio_files(user_dir) new_files = after - before if not new_files: return @@ -94,7 +117,7 @@ def convert_to_mp3(job_id: str, before: set[Path]): AUDIO_EXTS = {".m4a", ".ogg", ".opus", ".mp3", ".flac"} -def post_process_votify_files(target_dir, job_id): +def post_process_votify_files(target_dir, job_id, user_dir: Path): """Flatten subdirs, rename files from metadata, wrap single tracks.""" target_dir = Path(target_dir) @@ -125,8 +148,8 @@ def post_process_votify_files(target_dir, job_id): # 3. Clean up empty subdirs cleanup_empty_dirs(target_dir) - # 4. Single-track wrapping (only if downloading to root downloads dir) - if len(renamed) == 1 and target_dir == DOWNLOADS_DIR: + # 4. Single-track wrapping (only if downloading to user's root dir) + if len(renamed) == 1 and target_dir == user_dir: f = renamed[0] folder_name = sanitize_filename(f.stem) wrapper = target_dir / folder_name @@ -137,16 +160,22 @@ def post_process_votify_files(target_dir, job_id): return renamed -def run_download(job_id: str, urls: list[str], options: dict, output_path: str = None): - cmd = ["votify"] +# --------------------------------------------------------------------------- +# Download runners +# --------------------------------------------------------------------------- +def run_download(job_id: str, urls: list[str], options: dict, + output_path: str = None, user_id: str = None): + user_dir = get_user_downloads_dir(user_id) if user_id else DOWNLOADS_DIR + effective_output = output_path or str(user_dir) + + cmd = ["votify"] cmd.extend(["--cookies-path", str(COOKIES_PATH)]) - cmd.extend(["--output-path", output_path or str(DOWNLOADS_DIR)]) + cmd.extend(["--output-path", effective_output]) cmd.extend(["--temp-path", str(TEMP_DIR)]) if WVD_PATH.exists(): cmd.extend(["--wvd-path", str(WVD_PATH)]) - # Flatten folder structure — no nested Artist/Album subdirectories cmd.extend(["--template-folder-album", "."]) cmd.extend(["--template-folder-compilation", "."]) cmd.extend(["--template-folder-episode", "."]) @@ -170,19 +199,14 @@ def run_download(job_id: str, urls: list[str], options: dict, output_path: str = if options.get("save_cover"): cmd.append("--save-cover") - if options.get("save_playlist"): cmd.append("--save-playlist") - if options.get("overwrite"): cmd.append("--overwrite") - if options.get("download_music_videos"): cmd.append("--download-music-videos") - if options.get("save_lrc"): cmd.append("--lrc-only") - if options.get("no_lrc"): cmd.append("--no-lrc") @@ -193,7 +217,7 @@ def run_download(job_id: str, urls: list[str], options: dict, output_path: str = cmd.extend(urls) want_mp3 = options.get("output_format") == "mp3" - files_before = snapshot_audio_files(DOWNLOADS_DIR) if want_mp3 else None + files_before = snapshot_audio_files(user_dir) if want_mp3 else None with jobs_lock: jobs[job_id]["status"] = "running" @@ -230,10 +254,10 @@ def run_download(job_id: str, urls: list[str], options: dict, output_path: str = jobs[job_id]["output"] = jobs[job_id].get("output", []) + ["[cancelled] Job was cancelled by user."] else: if process.returncode == 0: - target = Path(output_path) if output_path else DOWNLOADS_DIR - post_process_votify_files(target, job_id) + target = Path(effective_output) + post_process_votify_files(target, job_id, user_dir) if want_mp3: - convert_to_mp3(job_id, files_before) + convert_to_mp3(job_id, files_before, user_dir) with jobs_lock: jobs[job_id]["status"] = "completed" if process.returncode == 0 else "failed" jobs[job_id]["return_code"] = process.returncode @@ -242,6 +266,152 @@ def run_download(job_id: str, urls: list[str], options: dict, output_path: str = jobs[job_id]["status"] = "failed" jobs[job_id]["output"] = jobs[job_id].get("output", []) + [str(e)] + with jobs_lock: + database.upsert_job(jobs[job_id]) + + +def run_monochrome_download(job_id: str, url: str, quality: str, user_id: str): + user_dir = get_user_downloads_dir(user_id) + + with jobs_lock: + jobs[job_id]["status"] = "running" + + def log(msg): + with jobs_lock: + jobs[job_id]["output"] = jobs[job_id].get("output", [])[-500:] + [msg] + + def is_cancelled(): + with jobs_lock: + return jobs[job_id]["status"] == "cancelled" + + try: + from monochrome.api import download_spotify_url + success, total, fail_info = download_spotify_url( + spotify_url=url, + quality=quality, + output_dir=str(user_dir), + log=log, + cancel_check=is_cancelled, + ) + with jobs_lock: + if jobs[job_id]["status"] != "cancelled": + jobs[job_id]["status"] = "completed" if success > 0 else "failed" + jobs[job_id]["return_code"] = 0 if success > 0 else 1 + except Exception as e: + with jobs_lock: + jobs[job_id]["status"] = "failed" + jobs[job_id]["output"] = jobs[job_id].get("output", []) + [f"[error] {e}"] + jobs[job_id]["return_code"] = 1 + + with jobs_lock: + database.upsert_job(jobs[job_id]) + + +def run_unified_download(job_id: str, url: str, user_id: str): + user_dir = get_user_downloads_dir(user_id) + + with jobs_lock: + jobs[job_id]["status"] = "running" + + def log(msg): + with jobs_lock: + jobs[job_id]["output"] = jobs[job_id].get("output", [])[-500:] + [msg] + + def is_cancelled(): + with jobs_lock: + return jobs[job_id]["status"] == "cancelled" + + try: + from monochrome.api import download_spotify_url + success, total, fail_info = download_spotify_url( + spotify_url=url, + quality="MP3_320", + output_dir=str(user_dir), + log=log, + cancel_check=is_cancelled, + ) + with jobs_lock: + if jobs[job_id]["status"] != "cancelled": + jobs[job_id]["status"] = "completed" if success > 0 else "failed" + jobs[job_id]["return_code"] = 0 if success > 0 else 1 + except Exception as e: + with jobs_lock: + jobs[job_id]["status"] = "failed" + jobs[job_id]["output"] = jobs[job_id].get("output", []) + [f"[error] {e}"] + jobs[job_id]["return_code"] = 1 + with jobs_lock: + database.upsert_job(jobs[job_id]) + return + + with jobs_lock: + cancelled = jobs[job_id]["status"] == "cancelled" + + failed_urls = fail_info.get("failed_urls", []) + if cancelled or not failed_urls: + with jobs_lock: + database.upsert_job(jobs[job_id]) + return + + # Spawn Votify fallback job for failed tracks + fallback_quality = database.get_setting("fallback_quality", "aac-medium") + + subfolder = fail_info.get("subfolder") + output_path = str(user_dir / subfolder) if subfolder else str(user_dir) + + votify_job_id = str(uuid.uuid4())[:8] + with jobs_lock: + jobs[votify_job_id] = { + "id": votify_job_id, + "user_id": user_id, + "urls": failed_urls, + "options": {"audio_quality": fallback_quality, "source": "votify-fallback"}, + "status": "queued", + "output": [], + "created_at": time.time(), + } + + log(f"[monochrome] {len(failed_urls)} track(s) failed — starting Votify fallback (job {votify_job_id})") + + with jobs_lock: + database.upsert_job(jobs[job_id]) + + run_download(votify_job_id, failed_urls, { + "audio_quality": fallback_quality, + "output_format": "mp3", + }, output_path=output_path, user_id=user_id) + + +# --------------------------------------------------------------------------- +# Background purge thread +# --------------------------------------------------------------------------- + +def _purge_loop(): + while True: + time.sleep(3600) + try: + days = int(database.get_setting("job_expiry_days", "30")) + if days <= 0: + continue + cutoff = time.time() - days * 86400 + database.delete_jobs_older_than(cutoff) + if not DOWNLOADS_DIR.exists(): + continue + for user_dir in DOWNLOADS_DIR.iterdir(): + if not user_dir.is_dir(): + continue + files = [f for f in user_dir.rglob("*") if f.is_file()] + if not files or max(f.stat().st_mtime for f in files) < cutoff: + shutil.rmtree(user_dir, ignore_errors=True) + except Exception as e: + print(f"[purge] {e}") + + +threading.Thread(target=_purge_loop, daemon=True).start() + + +# --------------------------------------------------------------------------- +# Static / offline routes +# --------------------------------------------------------------------------- @app.route("/sw.js") def service_worker(): @@ -253,14 +423,24 @@ def offline(): return send_from_directory("static", "offline.html") +# --------------------------------------------------------------------------- +# Auth routes +# --------------------------------------------------------------------------- + @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": + username = request.form.get("username", "").strip() password = request.form.get("password", "") - if password == APP_PASSWORD: - session["authenticated"] = True + user = database.get_user_by_username(username) + if user and database.verify_password(user, password): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["role"] = user["role"] + database.update_last_login(user["id"]) return redirect(url_for("index")) - return render_template("login.html", error="Incorrect password") + return render_template("login.html", error="Invalid username or password") return render_template("login.html", error=None) @@ -272,16 +452,40 @@ def logout(): @app.route("/") def index(): - return render_template("index.html", auth_enabled=bool(APP_PASSWORD)) + return render_template( + "index.html", + username=session.get("username"), + role=session.get("role"), + ) +# --------------------------------------------------------------------------- +# Account routes +# --------------------------------------------------------------------------- + +@app.route("/api/account/password", methods=["POST"]) +def change_password(): + data = request.json + user = database.get_user_by_id(session["user_id"]) + if not database.verify_password(user, data.get("current_password", "")): + return jsonify({"error": "Current password incorrect"}), 400 + new_pw = data.get("new_password", "") + if not new_pw: + return jsonify({"error": "New password cannot be empty"}), 400 + database.update_user_password(session["user_id"], new_pw) + return jsonify({"ok": True}) + + +# --------------------------------------------------------------------------- +# Download routes +# --------------------------------------------------------------------------- + @app.route("/api/download", methods=["POST"]) def start_download(): data = request.json urls = [u.strip() for u in data.get("urls", "").split("\n") if u.strip()] if not urls: return jsonify({"error": "No URLs provided"}), 400 - if not COOKIES_PATH.exists(): return jsonify({"error": "cookies.txt not found. Mount it to /config/cookies.txt"}), 400 @@ -300,10 +504,12 @@ def start_download(): "output_format": data.get("output_format", "original"), } + user_id = session["user_id"] job_id = str(uuid.uuid4())[:8] with jobs_lock: jobs[job_id] = { "id": job_id, + "user_id": user_id, "urls": urls, "options": options, "status": "queued", @@ -311,50 +517,18 @@ def start_download(): "created_at": time.time(), } - thread = threading.Thread(target=run_download, args=(job_id, urls, options), daemon=True) + thread = threading.Thread( + target=run_download, args=(job_id, urls, options, None, user_id), daemon=True + ) thread.start() - return jsonify({"job_id": job_id}) -def run_monochrome_download(job_id: str, url: str, quality: str): - with jobs_lock: - jobs[job_id]["status"] = "running" - - def log(msg): - with jobs_lock: - jobs[job_id]["output"] = jobs[job_id].get("output", [])[-500:] + [msg] - - def is_cancelled(): - with jobs_lock: - return jobs[job_id]["status"] == "cancelled" - - try: - from monochrome.api import download_spotify_url - success, total, fail_info = download_spotify_url( - spotify_url=url, - quality=quality, - output_dir=str(DOWNLOADS_DIR), - log=log, - cancel_check=is_cancelled, - ) - with jobs_lock: - if jobs[job_id]["status"] != "cancelled": - jobs[job_id]["status"] = "completed" if success > 0 else "failed" - jobs[job_id]["return_code"] = 0 if success > 0 else 1 - except Exception as e: - with jobs_lock: - jobs[job_id]["status"] = "failed" - jobs[job_id]["output"] = jobs[job_id].get("output", []) + [f"[error] {e}"] - jobs[job_id]["return_code"] = 1 - - @app.route("/api/monochrome/download", methods=["POST"]) def start_monochrome_download(): data = request.json url = data.get("url", "").strip() quality = data.get("quality", "HI_RES_LOSSLESS") - if not url: return jsonify({"error": "No URL provided"}), 400 @@ -362,10 +536,12 @@ def start_monochrome_download(): if quality not in valid_qualities: return jsonify({"error": f"Invalid quality. Choose from: {valid_qualities}"}), 400 + user_id = session["user_id"] job_id = str(uuid.uuid4())[:8] with jobs_lock: jobs[job_id] = { "id": job_id, + "user_id": user_id, "urls": [url], "options": {"quality": quality, "source": "monochrome"}, "status": "queued", @@ -374,105 +550,25 @@ def start_monochrome_download(): } thread = threading.Thread( - target=run_monochrome_download, args=(job_id, url, quality), daemon=True + target=run_monochrome_download, args=(job_id, url, quality, user_id), daemon=True ) thread.start() - return jsonify({"job_id": job_id}) -# --- Unified download (Monochrome → Votify fallback) --- - -SETTINGS_PATH = CONFIG_DIR / "settings.json" - - -def load_settings(): - if SETTINGS_PATH.exists(): - try: - return json.loads(SETTINGS_PATH.read_text()) - except (json.JSONDecodeError, OSError): - pass - return {"fallback_quality": "aac-medium"} - - -def run_unified_download(job_id: str, url: str): - with jobs_lock: - jobs[job_id]["status"] = "running" - - def log(msg): - with jobs_lock: - jobs[job_id]["output"] = jobs[job_id].get("output", [])[-500:] + [msg] - - def is_cancelled(): - with jobs_lock: - return jobs[job_id]["status"] == "cancelled" - - try: - from monochrome.api import download_spotify_url - success, total, fail_info = download_spotify_url( - spotify_url=url, - quality="MP3_320", - output_dir=str(DOWNLOADS_DIR), - log=log, - cancel_check=is_cancelled, - ) - with jobs_lock: - if jobs[job_id]["status"] != "cancelled": - jobs[job_id]["status"] = "completed" if success > 0 else "failed" - jobs[job_id]["return_code"] = 0 if success > 0 else 1 - except Exception as e: - with jobs_lock: - jobs[job_id]["status"] = "failed" - jobs[job_id]["output"] = jobs[job_id].get("output", []) + [f"[error] {e}"] - jobs[job_id]["return_code"] = 1 - return - - # Check if we should spawn Votify fallback - with jobs_lock: - cancelled = jobs[job_id]["status"] == "cancelled" - - failed_urls = fail_info.get("failed_urls", []) - if cancelled or not failed_urls: - return - - # Spawn Votify fallback job for failed tracks - settings = load_settings() - fallback_quality = settings.get("fallback_quality", "aac-medium") - - subfolder = fail_info.get("subfolder") - output_path = str(DOWNLOADS_DIR / subfolder) if subfolder else str(DOWNLOADS_DIR) - - votify_job_id = str(uuid.uuid4())[:8] - with jobs_lock: - jobs[votify_job_id] = { - "id": votify_job_id, - "urls": failed_urls, - "options": {"audio_quality": fallback_quality, "source": "votify-fallback"}, - "status": "queued", - "output": [], - "created_at": time.time(), - } - - log(f"[monochrome] {len(failed_urls)} track(s) failed — starting Votify fallback (job {votify_job_id})") - - run_download(votify_job_id, failed_urls, { - "audio_quality": fallback_quality, - "output_format": "mp3", - }, output_path=output_path) - - @app.route("/api/unified/download", methods=["POST"]) def start_unified_download(): data = request.json url = data.get("url", "").strip() - if not url: return jsonify({"error": "No URL provided"}), 400 + user_id = session["user_id"] job_id = str(uuid.uuid4())[:8] with jobs_lock: jobs[job_id] = { "id": job_id, + "user_id": user_id, "urls": [url], "options": {"source": "unified"}, "status": "queued", @@ -481,55 +577,82 @@ def start_unified_download(): } thread = threading.Thread( - target=run_unified_download, args=(job_id, url), daemon=True + target=run_unified_download, args=(job_id, url, user_id), daemon=True ) thread.start() - return jsonify({"job_id": job_id}) +# --------------------------------------------------------------------------- +# Settings routes +# --------------------------------------------------------------------------- + @app.route("/api/settings", methods=["GET"]) def get_settings(): - return jsonify(load_settings()) + return jsonify({ + "fallback_quality": database.get_setting("fallback_quality", "aac-medium"), + "job_expiry_days": int(database.get_setting("job_expiry_days", "30")), + }) @app.route("/api/settings", methods=["POST"]) def save_settings(): + guard = require_admin() + if guard: + return guard data = request.json allowed_qualities = ["aac-medium", "aac-high"] quality = data.get("fallback_quality", "aac-medium") if quality not in allowed_qualities: quality = "aac-medium" - settings = {"fallback_quality": quality} - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - SETTINGS_PATH.write_text(json.dumps(settings)) + database.set_setting("fallback_quality", quality) + if "job_expiry_days" in data: + try: + days = max(0, int(data["job_expiry_days"])) + except (ValueError, TypeError): + days = 30 + database.set_setting("job_expiry_days", str(days)) return jsonify({"ok": True}) +# --------------------------------------------------------------------------- +# Job routes +# --------------------------------------------------------------------------- + def job_to_dict(job): return {k: v for k, v in job.items() if k != "process"} @app.route("/api/jobs") def list_jobs(): + user_id = session["user_id"] + db_jobs = {j["id"]: j for j in database.list_jobs_for_user(user_id)} with jobs_lock: - return jsonify([job_to_dict(j) for j in jobs.values()]) + live = {jid: job_to_dict(j) for jid, j in jobs.items() + if j.get("user_id") == user_id} + db_jobs.update(live) + return jsonify(sorted(db_jobs.values(), key=lambda j: j["created_at"], reverse=True)) @app.route("/api/jobs/") def get_job(job_id): + user_id = session["user_id"] with jobs_lock: job = jobs.get(job_id) - if not job: + if job and job.get("user_id") == user_id: + return jsonify(job_to_dict(job)) + job = database.get_job(job_id) + if not job or job["user_id"] != user_id: return jsonify({"error": "Job not found"}), 404 - return jsonify(job_to_dict(job)) + return jsonify(job) @app.route("/api/jobs//cancel", methods=["POST"]) def cancel_job(job_id): + user_id = session["user_id"] with jobs_lock: job = jobs.get(job_id) - if not job: + if not job or job.get("user_id") != user_id: return jsonify({"error": "Job not found"}), 404 if job["status"] != "running": return jsonify({"error": "Job is not running"}), 400 @@ -542,23 +665,46 @@ def cancel_job(job_id): @app.route("/api/jobs/", methods=["DELETE"]) def delete_job(job_id): + user_id = session["user_id"] with jobs_lock: - if job_id in jobs: + job = jobs.get(job_id) + if job and job.get("user_id") == user_id: + if job["status"] == "running": + return jsonify({"error": "Cancel the job first"}), 400 del jobs[job_id] + database.delete_job(job_id) return jsonify({"ok": True}) +# --------------------------------------------------------------------------- +# File routes (scoped to current user) +# --------------------------------------------------------------------------- + +def _resolve_user_path(user_id: str, rel_path: str): + """Returns (user_dir, target) or raises ValueError on traversal.""" + user_dir = get_user_downloads_dir(user_id) + target = (user_dir / rel_path).resolve() + if not str(target).startswith(str(user_dir.resolve())): + raise ValueError("Invalid path") + return user_dir, target + + @app.route("/api/files") def list_files(): + user_id = session["user_id"] rel_path = request.args.get("path", "") - target = DOWNLOADS_DIR / rel_path + try: + user_dir, target = _resolve_user_path(user_id, rel_path) + except ValueError: + return jsonify({"error": "Invalid path"}), 403 + if not target.exists(): return jsonify([]) items = [] try: for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): - rel = entry.relative_to(DOWNLOADS_DIR) + rel = entry.relative_to(user_dir) items.append({ "name": entry.name, "path": str(rel).replace("\\", "/"), @@ -572,8 +718,12 @@ def list_files(): @app.route("/api/files/download") def download_file(): + user_id = session["user_id"] rel_path = request.args.get("path", "") - target = DOWNLOADS_DIR / rel_path + try: + _, target = _resolve_user_path(user_id, rel_path) + except ValueError: + return jsonify({"error": "Invalid path"}), 403 if not target.is_file(): return jsonify({"error": "File not found"}), 404 return send_from_directory(target.parent, target.name, as_attachment=True) @@ -581,8 +731,12 @@ def download_file(): @app.route("/api/files/download-folder") def download_folder(): + user_id = session["user_id"] rel_path = request.args.get("path", "") - target = DOWNLOADS_DIR / rel_path + try: + _, target = _resolve_user_path(user_id, rel_path) + except ValueError: + return jsonify({"error": "Invalid path"}), 403 if not target.is_dir(): return jsonify({"error": "Folder not found"}), 404 @@ -603,14 +757,16 @@ def download_folder(): @app.route("/api/files/delete", methods=["DELETE"]) def delete_path(): + user_id = session["user_id"] rel_path = request.args.get("path", "") if not rel_path: return jsonify({"error": "Cannot delete root"}), 400 - target = DOWNLOADS_DIR / rel_path + try: + user_dir, target = _resolve_user_path(user_id, rel_path) + except ValueError: + return jsonify({"error": "Invalid path"}), 403 if not target.exists(): return jsonify({"error": "Not found"}), 404 - if not str(target.resolve()).startswith(str(DOWNLOADS_DIR.resolve())): - return jsonify({"error": "Invalid path"}), 403 try: if target.is_dir(): shutil.rmtree(target) @@ -621,33 +777,254 @@ def delete_path(): return jsonify({"ok": True}) +# --------------------------------------------------------------------------- +# Cookies / WVD routes +# --------------------------------------------------------------------------- + @app.route("/api/cookies", methods=["GET"]) def check_cookies(): + guard = require_admin() + if guard: + return guard return jsonify({"exists": COOKIES_PATH.exists()}) @app.route("/api/cookies", methods=["POST"]) def upload_cookies(): + guard = require_admin() + if guard: + return guard if "file" not in request.files: return jsonify({"error": "No file uploaded"}), 400 - file = request.files["file"] CONFIG_DIR.mkdir(parents=True, exist_ok=True) - file.save(COOKIES_PATH) + request.files["file"].save(COOKIES_PATH) return jsonify({"ok": True}) @app.route("/api/wvd", methods=["GET"]) def check_wvd(): + guard = require_admin() + if guard: + return guard return jsonify({"exists": WVD_PATH.exists()}) @app.route("/api/wvd", methods=["POST"]) def upload_wvd(): + guard = require_admin() + if guard: + return guard if "file" not in request.files: return jsonify({"error": "No file uploaded"}), 400 - file = request.files["file"] CONFIG_DIR.mkdir(parents=True, exist_ok=True) - file.save(WVD_PATH) + request.files["file"].save(WVD_PATH) + return jsonify({"ok": True}) + + +# --------------------------------------------------------------------------- +# Admin routes +# --------------------------------------------------------------------------- + +def _admin_resolve_path(user_id: str, rel_path: str): + user_dir = get_user_downloads_dir(user_id) + target = (user_dir / rel_path).resolve() + if not str(target).startswith(str(user_dir.resolve())): + raise ValueError("Invalid path") + return user_dir, target + + +@app.route("/api/admin/users", methods=["GET"]) +def admin_list_users(): + guard = require_admin() + if guard: + return guard + return jsonify(database.list_users()) + + +@app.route("/api/admin/users", methods=["POST"]) +def admin_create_user(): + guard = require_admin() + if guard: + return guard + data = request.json + username = (data.get("username") or "").strip() + password = data.get("password") or "" + role = data.get("role", "user") + if not username or not password: + return jsonify({"error": "username and password required"}), 400 + if role not in ("admin", "user"): + role = "user" + try: + user = database.create_user(username, password, role) + except ValueError as e: + return jsonify({"error": str(e)}), 409 + return jsonify({"ok": True, "user": {k: user[k] for k in ("id", "username", "role")}}) + + +@app.route("/api/admin/users/", methods=["DELETE"]) +def admin_delete_user(user_id): + guard = require_admin() + if guard: + return guard + if user_id == session["user_id"]: + return jsonify({"error": "Cannot delete your own account"}), 400 + # Cancel and evict any live in-memory jobs belonging to this user + with jobs_lock: + for job in list(jobs.values()): + if job.get("user_id") == user_id: + proc = job.get("process") + if proc: + try: + proc.terminate() + except Exception: + pass + del jobs[job["id"]] + database.delete_user(user_id) # cascades to jobs table + user_dir = DOWNLOADS_DIR / user_id + if user_dir.exists(): + shutil.rmtree(user_dir, ignore_errors=True) + return jsonify({"ok": True}) + + +@app.route("/api/admin/users//password", methods=["POST"]) +def admin_reset_user_password(user_id): + guard = require_admin() + if guard: + return guard + data = request.json + new_password = (data or {}).get("new_password", "") + if not new_password: + return jsonify({"error": "Password cannot be empty"}), 400 + if not database.get_user_by_id(user_id): + return jsonify({"error": "User not found"}), 404 + database.update_user_password(user_id, new_password) + return jsonify({"ok": True}) + + +@app.route("/api/admin/users//jobs", methods=["GET"]) +def admin_list_user_jobs(user_id): + guard = require_admin() + if guard: + return guard + db_jobs = {j["id"]: j for j in database.list_jobs_for_user(user_id)} + with jobs_lock: + live = {jid: job_to_dict(j) for jid, j in jobs.items() + if j.get("user_id") == user_id} + db_jobs.update(live) + return jsonify(sorted(db_jobs.values(), key=lambda j: j["created_at"], reverse=True)) + + +@app.route("/api/admin/jobs/", methods=["DELETE"]) +def admin_delete_job(job_id): + guard = require_admin() + if guard: + return guard + with jobs_lock: + if job_id in jobs: + if jobs[job_id]["status"] == "running": + return jsonify({"error": "Cancel the job first"}), 400 + del jobs[job_id] + database.delete_job(job_id) + return jsonify({"ok": True}) + + +@app.route("/api/admin/files") +def admin_list_files(): + guard = require_admin() + if guard: + return guard + user_id = request.args.get("user_id", "") + rel_path = request.args.get("path", "") + if not user_id: + return jsonify({"error": "user_id required"}), 400 + try: + user_dir, target = _admin_resolve_path(user_id, rel_path) + except ValueError: + return jsonify({"error": "Invalid path"}), 403 + + if not target.exists(): + return jsonify([]) + items = [] + try: + for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): + rel = entry.relative_to(user_dir) + items.append({ + "name": entry.name, + "path": str(rel).replace("\\", "/"), + "is_dir": entry.is_dir(), + "size": entry.stat().st_size if entry.is_file() else None, + }) + except PermissionError: + pass + return jsonify(items) + + +@app.route("/api/admin/files/download") +def admin_download_file(): + guard = require_admin() + if guard: + return guard + user_id = request.args.get("user_id", "") + rel_path = request.args.get("path", "") + try: + _, target = _admin_resolve_path(user_id, rel_path) + except ValueError: + return jsonify({"error": "Invalid path"}), 403 + if not target.is_file(): + return jsonify({"error": "File not found"}), 404 + return send_from_directory(target.parent, target.name, as_attachment=True) + + +@app.route("/api/admin/files/download-folder") +def admin_download_folder(): + guard = require_admin() + if guard: + return guard + user_id = request.args.get("user_id", "") + rel_path = request.args.get("path", "") + try: + _, target = _admin_resolve_path(user_id, rel_path) + except ValueError: + return jsonify({"error": "Invalid path"}), 403 + if not target.is_dir(): + return jsonify({"error": "Folder not found"}), 404 + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for file in target.rglob("*"): + if file.is_file(): + zf.write(file, file.relative_to(target)) + buf.seek(0) + folder_name = target.name or "downloads" + return app.response_class( + buf.getvalue(), + mimetype="application/zip", + headers={"Content-Disposition": f'attachment; filename="{folder_name}.zip"'}, + ) + + +@app.route("/api/admin/files/delete", methods=["DELETE"]) +def admin_delete_file(): + guard = require_admin() + if guard: + return guard + user_id = request.args.get("user_id", "") + rel_path = request.args.get("path", "") + if not rel_path: + return jsonify({"error": "Cannot delete root"}), 400 + try: + _, target = _admin_resolve_path(user_id, rel_path) + except ValueError: + return jsonify({"error": "Invalid path"}), 403 + if not target.exists(): + return jsonify({"error": "Not found"}), 404 + try: + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + except Exception as e: + return jsonify({"error": str(e)}), 500 return jsonify({"ok": True}) diff --git a/db.py b/db.py new file mode 100644 index 0000000..dcef148 --- /dev/null +++ b/db.py @@ -0,0 +1,232 @@ +import json +import os +import sqlite3 +import threading +import time +import uuid +from pathlib import Path + +from werkzeug.security import check_password_hash, generate_password_hash + +_db_path: Path | None = None +_local = threading.local() + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at REAL NOT NULL, + last_login REAL +); + +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + urls TEXT NOT NULL, + options TEXT NOT NULL, + status TEXT NOT NULL, + output TEXT NOT NULL DEFAULT '[]', + command TEXT, + return_code INTEGER, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +""" + + +def init_db(db_path: Path) -> None: + global _db_path + _db_path = db_path + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.executescript(_SCHEMA) + conn.commit() + _seed_admin(conn) + conn.close() + + +def _seed_admin(conn: sqlite3.Connection) -> None: + username = os.environ.get("ADMIN_USERNAME", "").strip() + password = os.environ.get("ADMIN_PASSWORD", "").strip() + if not username or not password: + return + row = conn.execute("SELECT COUNT(*) FROM users").fetchone() + if row[0] > 0: + return + conn.execute( + "INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, 'admin', ?)", + (str(uuid.uuid4()), username, generate_password_hash(password), time.time()), + ) + conn.commit() + + +def get_db() -> sqlite3.Connection: + conn = getattr(_local, "conn", None) + if conn is None: + conn = sqlite3.connect(str(_db_path), check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + _local.conn = conn + return conn + + +def _row(r) -> dict | None: + return dict(r) if r else None + + +# --------------------------------------------------------------------------- +# Users +# --------------------------------------------------------------------------- + +def create_user(username: str, password: str, role: str = "user") -> dict: + db = get_db() + user_id = str(uuid.uuid4()) + try: + db.execute( + "INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)", + (user_id, username, generate_password_hash(password), role, time.time()), + ) + db.commit() + except sqlite3.IntegrityError: + raise ValueError(f"Username '{username}' already exists") + return _row(db.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()) + + +def get_user_by_username(username: str) -> dict | None: + db = get_db() + return _row(db.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()) + + +def get_user_by_id(user_id: str) -> dict | None: + db = get_db() + return _row(db.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()) + + +def list_users() -> list[dict]: + db = get_db() + return [dict(r) for r in db.execute( + "SELECT id, username, role, created_at, last_login FROM users ORDER BY created_at" + ).fetchall()] + + +def delete_user(user_id: str) -> None: + db = get_db() + db.execute("DELETE FROM users WHERE id = ?", (user_id,)) + db.commit() + + +def update_user_password(user_id: str, new_password: str) -> None: + db = get_db() + db.execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + (generate_password_hash(new_password), user_id), + ) + db.commit() + + +def update_last_login(user_id: str) -> None: + db = get_db() + db.execute("UPDATE users SET last_login = ? WHERE id = ?", (time.time(), user_id)) + db.commit() + + +def verify_password(user: dict, password: str) -> bool: + return check_password_hash(user["password_hash"], password) + + +# --------------------------------------------------------------------------- +# Jobs +# --------------------------------------------------------------------------- + +def _job_from_row(row) -> dict | None: + if not row: + return None + d = dict(row) + d["urls"] = json.loads(d["urls"]) + d["options"] = json.loads(d["options"]) + d["output"] = json.loads(d["output"]) + return d + + +def upsert_job(job: dict) -> None: + db = get_db() + db.execute( + """INSERT OR REPLACE INTO jobs + (id, user_id, urls, options, status, output, command, return_code, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + job["id"], + job.get("user_id", ""), + json.dumps(job.get("urls", [])), + json.dumps(job.get("options", {})), + job.get("status", "unknown"), + json.dumps(job.get("output", [])), + job.get("command"), + job.get("return_code"), + job.get("created_at", time.time()), + time.time(), + ), + ) + db.commit() + + +def get_job(job_id: str) -> dict | None: + db = get_db() + return _job_from_row(db.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()) + + +def list_jobs_for_user(user_id: str) -> list[dict]: + db = get_db() + rows = db.execute( + "SELECT * FROM jobs WHERE user_id = ? ORDER BY created_at DESC", (user_id,) + ).fetchall() + return [_job_from_row(r) for r in rows] + + +def list_all_jobs() -> list[dict]: + db = get_db() + rows = db.execute("SELECT * FROM jobs ORDER BY created_at DESC").fetchall() + return [_job_from_row(r) for r in rows] + + +def delete_job(job_id: str) -> None: + db = get_db() + db.execute("DELETE FROM jobs WHERE id = ?", (job_id,)) + db.commit() + + +def delete_jobs_older_than(cutoff: float) -> None: + db = get_db() + db.execute("DELETE FROM jobs WHERE created_at < ?", (cutoff,)) + db.commit() + + +# --------------------------------------------------------------------------- +# Settings +# --------------------------------------------------------------------------- + +def get_setting(key: str, default: str | None = None) -> str | None: + db = get_db() + row = db.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone() + return row["value"] if row else default + + +def set_setting(key: str, value: str) -> None: + db = get_db() + db.execute( + "INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)", (key, value) + ) + db.commit() + + +def get_all_settings() -> dict: + db = get_db() + return {row["key"]: row["value"] for row in db.execute("SELECT key, value FROM app_settings").fetchall()} diff --git a/firebase-debug.log b/firebase-debug.log new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index a6ac7e4..03f9ad0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ flask==3.1.0 gunicorn==23.0.0 mutagen +werkzeug>=3.0 diff --git a/templates/index.html b/templates/index.html index 6a0bcb4..2452ee8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -160,6 +160,9 @@ + {% if role == 'admin' %} + + {% endif %} @@ -294,14 +297,73 @@ + + {% if role == 'admin' %} +
+
+

User Management

+ + +
+
+ +
+ {% endif %} +
- {% if auth_enabled %}

Account

- Logout +

Signed in as {{ username }}

+
+ + +
+
+ + +
+
+ + +
+
+ + Logout +
- {% endif %} + {% if role == 'admin' %}

Fallback Quality

Quality for fallback when Monochrome can't find a track

@@ -310,6 +372,17 @@
+
+

Job & File Expiry

+

Delete jobs and download files older than this many days (0 = never)

+
+ + days + +
+
+ {% endif %} + {% if role == 'admin' %}

Cookies

+ {% endif %}
@@ -354,6 +428,12 @@ Settings + {% if role == 'admin' %} + + {% endif %}
@@ -376,7 +456,8 @@ if (name === 'jobs') loadJobs(); if (name === 'files') loadFiles(""); - if (name === 'settings') { checkCookies(); checkWvd(); loadFallbackQuality(); } + if (name === 'settings') { {% if role == 'admin' %}checkCookies(); checkWvd();{% endif %} loadFallbackQuality(); } + if (name === 'admin') loadUsers(); if (jobPollInterval) clearInterval(jobPollInterval); if (name === 'jobs') jobPollInterval = setInterval(loadJobs, 3000); @@ -477,11 +558,15 @@ try { const res = await fetch('/api/settings'); const data = await res.json(); - document.getElementById('fallback-quality').value = data.fallback_quality || 'aac-medium'; + const fq = document.getElementById('fallback-quality'); + if (fq) fq.value = data.fallback_quality || 'aac-medium'; + const ed = document.getElementById('job-expiry-days'); + if (ed) ed.value = data.job_expiry_days ?? 30; } catch (_) {} } - document.getElementById('fallback-quality').addEventListener('change', async function() { + const fqEl = document.getElementById('fallback-quality'); + if (fqEl) fqEl.addEventListener('change', async function() { try { await fetch('/api/settings', { method: 'POST', @@ -494,6 +579,263 @@ } }); + async function saveExpiryDays() { + const days = parseInt(document.getElementById('job-expiry-days').value) || 0; + try { + await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ job_expiry_days: days }) + }); + showToast('Expiry saved'); + } catch (e) { + showToast('Error saving expiry', 'error'); + } + } + + async function changePassword() { + const current = document.getElementById('current-password').value; + const next = document.getElementById('new-password').value; + const confirm = document.getElementById('confirm-password').value; + if (!current || !next || !confirm) { showToast('Fill in all password fields', 'error'); return; } + if (next !== confirm) { showToast('New passwords do not match', 'error'); return; } + try { + const res = await fetch('/api/account/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ current_password: current, new_password: next }) + }); + const data = await res.json(); + if (data.ok) { + showToast('Password changed'); + document.getElementById('current-password').value = ''; + document.getElementById('new-password').value = ''; + document.getElementById('confirm-password').value = ''; + } else { + showToast(data.error || 'Error changing password', 'error'); + } + } catch (e) { + showToast('Error changing password', 'error'); + } + } + + // --------------------------------------------------------------------------- + // Admin functions + // --------------------------------------------------------------------------- + + let adminViewingUserId = null; + let adminViewingUserName = null; + let adminUserDetailPath = ''; + + function toggleCreateUserForm() { + const f = document.getElementById('create-user-form'); + f.style.display = f.style.display === 'none' ? 'block' : 'none'; + } + + async function loadUsers() { + const list = document.getElementById('user-list'); + if (!list) return; + try { + const res = await fetch('/api/admin/users'); + if (!res.ok) { + const text = await res.text(); + console.error('loadUsers HTTP error', res.status, text); + list.innerHTML = `

Error loading users (HTTP ${res.status})

`; + return; + } + const users = await res.json(); + if (!Array.isArray(users) || !users.length) { list.innerHTML = '

No users yet.

'; return; } + list.innerHTML = users.map(u => ` +
+
+ ${esc(u.username)} + ${esc(u.role)} +
Created ${new Date(u.created_at * 1000).toLocaleDateString()}
+
+ + + +
`).join(''); + } catch (e) { + console.error('loadUsers error', e); + list.innerHTML = `

Error loading users: ${esc(String(e))}

`; + } + } + + async function createUser() { + const username = document.getElementById('new-username').value.trim(); + const password = document.getElementById('new-user-password').value; + const role = document.getElementById('new-user-role').value; + if (!username || !password) { showToast('Username and password required', 'error'); return; } + try { + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, role }) + }); + const data = await res.json(); + if (data.ok) { + showToast('User created'); + document.getElementById('new-username').value = ''; + document.getElementById('new-user-password').value = ''; + toggleCreateUserForm(); + loadUsers(); + } else { + showToast(data.error || 'Error creating user', 'error'); + } + } catch (e) { + showToast('Error creating user', 'error'); + } + } + + async function deleteUser(userId, username) { + if (!confirm(`Delete user "${username}" and all their data?`)) return; + try { + const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.ok) { showToast('User deleted'); loadUsers(); } + else showToast(data.error || 'Error deleting user', 'error'); + } catch (e) { + showToast('Error deleting user', 'error'); + } + } + + function viewUser(userId, username) { + adminViewingUserId = userId; + adminViewingUserName = username; + adminUserDetailPath = ''; + document.getElementById('user-detail-title').textContent = username + "'s Data"; + document.getElementById('admin-user-list-card').style.display = 'none'; + document.getElementById('user-detail-panel').style.display = 'block'; + switchUserDetailTab('jobs'); + } + + function closeUserDetail() { + adminViewingUserId = null; + document.getElementById('user-detail-panel').style.display = 'none'; + document.getElementById('admin-user-list-card').style.display = 'block'; + } + + function switchUserDetailTab(tab) { + document.getElementById('user-detail-jobs').style.display = tab === 'jobs' ? 'block' : 'none'; + document.getElementById('user-detail-files').style.display = tab === 'files' ? 'block' : 'none'; + document.getElementById('user-detail-tab-jobs').style.fontWeight = tab === 'jobs' ? '700' : ''; + document.getElementById('user-detail-tab-files').style.fontWeight = tab === 'files' ? '700' : ''; + if (tab === 'jobs') loadUserJobs(); + if (tab === 'files') loadUserFiles(''); + } + + async function loadUserJobs() { + const container = document.getElementById('user-detail-jobs'); + try { + const res = await fetch(`/api/admin/users/${adminViewingUserId}/jobs`); + const jobs = await res.json(); + if (!jobs.length) { container.innerHTML = '

No jobs.

'; return; } + container.innerHTML = jobs.map(j => ` +
+
+ ${esc(j.id)} + ${esc(j.status)} + ${new Date(j.created_at * 1000).toLocaleString()} + +
+
${(j.urls || []).map(esc).join(', ')}
+
`).join(''); + } catch (e) { + container.innerHTML = '

Error loading jobs

'; + } + } + + async function adminDeleteJob(jobId) { + if (!confirm('Delete this job?')) return; + try { + const res = await fetch(`/api/admin/jobs/${jobId}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.ok) { showToast('Job deleted'); loadUserJobs(); } + else showToast(data.error || 'Error deleting job', 'error'); + } catch (e) { + showToast('Error deleting job', 'error'); + } + } + + async function loadUserFiles(relPath) { + adminUserDetailPath = relPath; + const container = document.getElementById('user-detail-files'); + const params = new URLSearchParams({ user_id: adminViewingUserId, path: relPath }); + try { + const res = await fetch(`/api/admin/files?${params}`); + const items = await res.json(); + + let breadcrumbHtml = 'Root'; + if (relPath) { + const parts = relPath.split('/'); + parts.forEach((p, i) => { + const partial = parts.slice(0, i + 1).join('/'); + breadcrumbHtml += ` / ${esc(p)}`; + }); + } + + if (!items.length) { + container.innerHTML = `

Empty.

`; + return; + } + + const rows = items.map(item => { + const icon = item.is_dir ? '📁' : '🎵'; + const size = item.is_dir ? '' : formatSize(item.size); + const dlParams = new URLSearchParams({ user_id: adminViewingUserId, path: item.path }); + const dlUrl = item.is_dir + ? `/api/admin/files/download-folder?${dlParams}` + : `/api/admin/files/download?${dlParams}`; + return `
+ ${icon} + ${esc(item.name)} + ${size ? `${size}` : ''} + + +
`; + }).join(''); + + container.innerHTML = `
${rows}
`; + } catch (e) { + container.innerHTML = '

Error loading files

'; + } + } + + async function adminDeleteFile(relPath) { + if (!confirm('Delete this item?')) return; + const params = new URLSearchParams({ user_id: adminViewingUserId, path: relPath }); + try { + const res = await fetch(`/api/admin/files/delete?${params}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.ok) { showToast('Deleted'); loadUserFiles(adminUserDetailPath); } + else showToast(data.error || 'Error deleting', 'error'); + } catch (e) { + showToast('Error deleting', 'error'); + } + } + + async function promptResetPassword(userId, username) { + const pw = prompt(`Set new password for "${username}":`); + if (pw === null) return; + if (!pw) { showToast('Password cannot be empty', 'error'); return; } + const pw2 = prompt(`Confirm new password for "${username}":`); + if (pw2 === null) return; + if (pw !== pw2) { showToast('Passwords do not match', 'error'); return; } + try { + const res = await fetch(`/api/admin/users/${userId}/password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ new_password: pw }) + }); + const data = await res.json(); + if (data.ok) showToast(`Password updated for ${username}`); + else showToast(data.error || 'Error resetting password', 'error'); + } catch (e) { + showToast('Error resetting password', 'error'); + } + } + function parseProgress(output) { if (!output || output.length === 0) return null; let current = 0, total = 0, dlPct = 0; @@ -767,6 +1109,7 @@ function escapeAttr(str) { return str.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(//g,'>'); } + const esc = escapeHtml; function showToast(msg, type) { const toast = document.getElementById('toast'); @@ -844,8 +1187,7 @@ document.getElementById('advanced-section').classList.add('open'); document.getElementById('advanced-toggle').classList.add('open'); } - checkCookies(); - checkWvd(); + {% if role == 'admin' %}checkCookies(); checkWvd();{% endif %} if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js') diff --git a/templates/login.html b/templates/login.html index 7a61e49..a53a83c 100644 --- a/templates/login.html +++ b/templates/login.html @@ -24,7 +24,8 @@ .login-card h1 { font-size: 1.4rem; margin-bottom: 8px; } .login-card h1 span { color: var(--accent); } .login-card p { color: var(--text2); font-size: 0.85rem; margin-bottom: 24px; } - .login-card input[type="password"] { + .login-card input[type="password"], + .login-card input[type="text"] { width: 100%; background: var(--surface2); border: 1px solid #333; color: var(--text); border-radius: var(--radius); padding: 12px 14px; font-size: 0.95rem; font-family: inherit; margin-bottom: 16px; transition: border-color 0.2s; @@ -41,9 +42,10 @@