feat: implemented account system
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Set a password to protect the app. Leave empty to disable auth.
|
# Admin account seeded on first run (if no users exist yet).
|
||||||
PASSWORD=
|
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.
|
# Host port to expose the app on.
|
||||||
PORT=5000
|
PORT=5000
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ COPY requirements.txt /app/requirements.txt
|
|||||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
COPY app.py /app/app.py
|
COPY app.py /app/app.py
|
||||||
|
COPY db.py /app/db.py
|
||||||
COPY utils.py /app/utils.py
|
COPY utils.py /app/utils.py
|
||||||
COPY templates /app/templates
|
COPY templates /app/templates
|
||||||
COPY static /app/static
|
COPY static /app/static
|
||||||
|
|||||||
232
db.py
Normal file
232
db.py
Normal file
@@ -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()}
|
||||||
0
firebase-debug.log
Normal file
0
firebase-debug.log
Normal file
@@ -1,3 +1,4 @@
|
|||||||
flask==3.1.0
|
flask==3.1.0
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
mutagen
|
mutagen
|
||||||
|
werkzeug>=3.0
|
||||||
|
|||||||
@@ -160,6 +160,9 @@
|
|||||||
<button class="tab" onclick="showPage('jobs')">Jobs</button>
|
<button class="tab" onclick="showPage('jobs')">Jobs</button>
|
||||||
<button class="tab" onclick="showPage('files')">Files</button>
|
<button class="tab" onclick="showPage('files')">Files</button>
|
||||||
<button class="tab" onclick="showPage('settings')">Settings</button>
|
<button class="tab" onclick="showPage('settings')">Settings</button>
|
||||||
|
{% if role == 'admin' %}
|
||||||
|
<button class="tab" onclick="showPage('admin')">Users</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -294,14 +297,73 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SETTINGS PAGE -->
|
<!-- ADMIN PAGE -->
|
||||||
<div id="page-settings" class="page">
|
{% if role == 'admin' %}
|
||||||
{% if auth_enabled %}
|
<div id="page-admin" class="page">
|
||||||
|
<div class="card" id="admin-user-list-card">
|
||||||
|
<h2>User Management</h2>
|
||||||
|
<button class="btn btn-sm" style="margin-bottom:16px" onclick="toggleCreateUserForm()">+ Create User</button>
|
||||||
|
<div id="create-user-form" style="display:none; margin-bottom:16px; padding:16px; background:var(--surface2); border-radius:var(--radius)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" id="new-username" placeholder="Username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" id="new-user-password" placeholder="Password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Role</label>
|
||||||
|
<select id="new-user-role">
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px; margin-top:8px">
|
||||||
|
<button class="btn btn-sm" onclick="createUser()">Create</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="toggleCreateUserForm()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="user-list"></div>
|
||||||
|
</div>
|
||||||
|
<div id="user-detail-panel" style="display:none">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Account</h2>
|
<button class="btn btn-sm btn-secondary" style="margin-bottom:12px" onclick="closeUserDetail()">← Back to Users</button>
|
||||||
<a href="/logout" class="btn btn-danger btn-sm" style="text-decoration:none">Logout</a>
|
<h2 id="user-detail-title"></h2>
|
||||||
|
<div style="display:flex; gap:8px; margin-bottom:16px">
|
||||||
|
<button class="btn btn-sm btn-secondary" id="user-detail-tab-jobs" onclick="switchUserDetailTab('jobs')">Jobs</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" id="user-detail-tab-files" onclick="switchUserDetailTab('files')">Files</button>
|
||||||
|
</div>
|
||||||
|
<div id="user-detail-jobs"></div>
|
||||||
|
<div id="user-detail-files" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- SETTINGS PAGE -->
|
||||||
|
<div id="page-settings" class="page">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Account</h2>
|
||||||
|
<p style="color:var(--text2); font-size:0.85rem; margin-bottom:12px">Signed in as <strong>{{ username }}</strong></p>
|
||||||
|
<div class="form-group" style="margin-bottom:8px">
|
||||||
|
<label>Current Password</label>
|
||||||
|
<input type="password" id="current-password" placeholder="Current password" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:8px">
|
||||||
|
<label>New Password</label>
|
||||||
|
<input type="password" id="new-password" placeholder="New password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:12px">
|
||||||
|
<label>Confirm New Password</label>
|
||||||
|
<input type="password" id="confirm-password" placeholder="Confirm new password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center">
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="changePassword()">Change Password</button>
|
||||||
|
<a href="/logout" class="btn btn-danger btn-sm" style="text-decoration:none">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if role == 'admin' %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Fallback Quality</h2>
|
<h2>Fallback Quality</h2>
|
||||||
<p style="font-size:0.85rem; color:var(--text2); margin-bottom:12px">Quality for fallback when Monochrome can't find a track</p>
|
<p style="font-size:0.85rem; color:var(--text2); margin-bottom:12px">Quality for fallback when Monochrome can't find a track</p>
|
||||||
@@ -310,6 +372,17 @@
|
|||||||
<option value="aac-high">AAC 256kbps (Premium)</option>
|
<option value="aac-high">AAC 256kbps (Premium)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Job & File Expiry</h2>
|
||||||
|
<p style="font-size:0.85rem; color:var(--text2); margin-bottom:12px">Delete jobs and download files older than this many days (0 = never)</p>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center">
|
||||||
|
<input type="number" id="job-expiry-days" min="0" max="365" style="width:90px; background:var(--surface2); border:1px solid #333; color:var(--text); border-radius:var(--radius); padding:8px 10px; font-size:0.9rem; font-family:inherit">
|
||||||
|
<span style="color:var(--text2); font-size:0.85rem">days</span>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="saveExpiryDays()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if role == 'admin' %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Cookies</h2>
|
<h2>Cookies</h2>
|
||||||
<div class="cookie-status" id="cookie-status">
|
<div class="cookie-status" id="cookie-status">
|
||||||
@@ -334,6 +407,7 @@
|
|||||||
<button class="btn btn-sm btn-secondary" style="margin-top:12px" onclick="uploadWvd()">Upload</button>
|
<button class="btn btn-sm btn-secondary" style="margin-top:12px" onclick="uploadWvd()">Upload</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -354,6 +428,12 @@
|
|||||||
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg></span>
|
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg></span>
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</button>
|
</button>
|
||||||
|
{% if role == 'admin' %}
|
||||||
|
<button class="bottom-tab" data-page="admin" onclick="showPage('admin')">
|
||||||
|
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
|
||||||
|
<span>Users</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
@@ -376,7 +456,8 @@
|
|||||||
|
|
||||||
if (name === 'jobs') loadJobs();
|
if (name === 'jobs') loadJobs();
|
||||||
if (name === 'files') loadFiles("");
|
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 (jobPollInterval) clearInterval(jobPollInterval);
|
||||||
if (name === 'jobs') jobPollInterval = setInterval(loadJobs, 3000);
|
if (name === 'jobs') jobPollInterval = setInterval(loadJobs, 3000);
|
||||||
@@ -477,11 +558,15 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings');
|
const res = await fetch('/api/settings');
|
||||||
const data = await res.json();
|
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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('fallback-quality').addEventListener('change', async function() {
|
const fqEl = document.getElementById('fallback-quality');
|
||||||
|
if (fqEl) fqEl.addEventListener('change', async function() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/settings', {
|
await fetch('/api/settings', {
|
||||||
method: 'POST',
|
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 = `<p style="color:var(--danger)">Error loading users (HTTP ${res.status})</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const users = await res.json();
|
||||||
|
if (!Array.isArray(users) || !users.length) { list.innerHTML = '<p style="color:var(--text2);font-size:0.85rem">No users yet.</p>'; return; }
|
||||||
|
list.innerHTML = users.map(u => `
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; padding:10px 0; border-bottom:1px solid var(--surface2)">
|
||||||
|
<div style="flex:1">
|
||||||
|
<span style="font-weight:600">${esc(u.username)}</span>
|
||||||
|
<span class="status-badge ${u.role === 'admin' ? 'status-running' : 'status-queued'}" style="margin-left:8px">${esc(u.role)}</span>
|
||||||
|
<div style="font-size:0.75rem; color:var(--text2); margin-top:2px">Created ${new Date(u.created_at * 1000).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="viewUser('${esc(u.id)}','${esc(u.username)}')">View</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="promptResetPassword('${esc(u.id)}','${esc(u.username)}')">Reset PW</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteUser('${esc(u.id)}','${esc(u.username)}')">Delete</button>
|
||||||
|
</div>`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadUsers error', e);
|
||||||
|
list.innerHTML = `<p style="color:var(--danger)">Error loading users: ${esc(String(e))}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<p style="color:var(--text2);font-size:0.85rem">No jobs.</p>'; return; }
|
||||||
|
container.innerHTML = jobs.map(j => `
|
||||||
|
<div class="job-card" style="background:var(--surface2); border-radius:var(--radius); padding:12px; margin-bottom:8px">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; margin-bottom:4px">
|
||||||
|
<code style="font-size:0.8rem">${esc(j.id)}</code>
|
||||||
|
<span class="status-badge status-${esc(j.status)}">${esc(j.status)}</span>
|
||||||
|
<span style="margin-left:auto; font-size:0.75rem; color:var(--text2)">${new Date(j.created_at * 1000).toLocaleString()}</span>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="adminDeleteJob('${esc(j.id)}')">Delete</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.8rem; color:var(--text2)">${(j.urls || []).map(esc).join(', ')}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<p style="color:var(--danger)">Error loading jobs</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<span style="cursor:pointer;color:var(--accent)" onclick="loadUserFiles(\'\')">Root</span>';
|
||||||
|
if (relPath) {
|
||||||
|
const parts = relPath.split('/');
|
||||||
|
parts.forEach((p, i) => {
|
||||||
|
const partial = parts.slice(0, i + 1).join('/');
|
||||||
|
breadcrumbHtml += ` / <span style="cursor:pointer;color:var(--accent)" onclick="loadUserFiles('${esc(partial)}')">${esc(p)}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
container.innerHTML = `<div class="breadcrumb" style="margin-bottom:12px">${breadcrumbHtml}</div><p style="color:var(--text2);font-size:0.85rem">Empty.</p>`;
|
||||||
|
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 `<div class="file-item">
|
||||||
|
<span class="file-icon">${icon}</span>
|
||||||
|
<span class="file-name" style="cursor:pointer;flex:1" onclick="${item.is_dir ? `loadUserFiles('${esc(item.path)}')` : ''}">${esc(item.name)}</span>
|
||||||
|
${size ? `<span class="file-size">${size}</span>` : ''}
|
||||||
|
<a href="${dlUrl}" class="btn btn-sm btn-secondary" style="text-decoration:none">↓</a>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="adminDeleteFile('${esc(item.path)}')">✕</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `<div class="breadcrumb" style="margin-bottom:12px">${breadcrumbHtml}</div><div class="file-list">${rows}</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = '<p style="color:var(--danger)">Error loading files</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function parseProgress(output) {
|
||||||
if (!output || output.length === 0) return null;
|
if (!output || output.length === 0) return null;
|
||||||
let current = 0, total = 0, dlPct = 0;
|
let current = 0, total = 0, dlPct = 0;
|
||||||
@@ -767,6 +1109,7 @@
|
|||||||
function escapeAttr(str) {
|
function escapeAttr(str) {
|
||||||
return str.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
return str.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||||
}
|
}
|
||||||
|
const esc = escapeHtml;
|
||||||
|
|
||||||
function showToast(msg, type) {
|
function showToast(msg, type) {
|
||||||
const toast = document.getElementById('toast');
|
const toast = document.getElementById('toast');
|
||||||
@@ -844,8 +1187,7 @@
|
|||||||
document.getElementById('advanced-section').classList.add('open');
|
document.getElementById('advanced-section').classList.add('open');
|
||||||
document.getElementById('advanced-toggle').classList.add('open');
|
document.getElementById('advanced-toggle').classList.add('open');
|
||||||
}
|
}
|
||||||
checkCookies();
|
{% if role == 'admin' %}checkCookies(); checkWvd();{% endif %}
|
||||||
checkWvd();
|
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('/sw.js')
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
.login-card h1 { font-size: 1.4rem; margin-bottom: 8px; }
|
.login-card h1 { font-size: 1.4rem; margin-bottom: 8px; }
|
||||||
.login-card h1 span { color: var(--accent); }
|
.login-card h1 span { color: var(--accent); }
|
||||||
.login-card p { color: var(--text2); font-size: 0.85rem; margin-bottom: 24px; }
|
.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);
|
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;
|
border-radius: var(--radius); padding: 12px 14px; font-size: 0.95rem; font-family: inherit;
|
||||||
margin-bottom: 16px; transition: border-color 0.2s;
|
margin-bottom: 16px; transition: border-color 0.2s;
|
||||||
@@ -41,9 +42,10 @@
|
|||||||
<body>
|
<body>
|
||||||
<form class="login-card" method="POST" action="/login">
|
<form class="login-card" method="POST" action="/login">
|
||||||
<h1><span>Track</span>pull</h1>
|
<h1><span>Track</span>pull</h1>
|
||||||
<p>Enter password to continue</p>
|
<p>Sign in to continue</p>
|
||||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||||
<input type="password" name="password" placeholder="Password" autofocus required>
|
<input type="text" name="username" placeholder="Username" autofocus required autocomplete="username">
|
||||||
|
<input type="password" name="password" placeholder="Password" required autocomplete="current-password">
|
||||||
<button type="submit">Sign In</button>
|
<button type="submit">Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user