refactor: (deepseekv4) login with cookies (detonatorui), remove CORS

This commit is contained in:
Dobin Rutishauser
2026-05-15 16:15:58 +02:00
parent 3fcedf0fd0
commit aace1f3f15
19 changed files with 310 additions and 323 deletions
-22
View File
@@ -16,10 +16,7 @@ from detonatorapi.logging_config import setup_logging
from detonatorapi.fastapi_app import app as fastapi_app
from detonatorapi.connectors.connectors import connectors
from detonatorapi.database import File
from detonatorapi.settings import CORS_ALLOW_ORIGINS
from detonatorui.config import API_BASE_URL
from detonatorapi.database import get_db_direct
from detonatorapi.db_interface import db_list_profiles
@@ -85,24 +82,6 @@ def parse_arguments():
return parser.parse_args()
def print_cors_help():
"""Print helpful information about CORS configuration."""
print("\n" + "="*70)
print("⚠️ CORS CONFIGURATION")
print("="*70)
print(f"\nThe Browser UI JavaScript will attempt to connect to the API:")
print(f" - {API_BASE_URL}")
print(f"")
print(f"Where the API current CORS allowed value:")
print(f" - {CORS_ALLOW_ORIGINS}")
print(f"")
print(f"To change CORS settings:")
print(f" Option A - Environment variable:")
print(f" export DETONATOR_CORS_ORIGINS='http://detonator.r00ted.ch'")
print(f" Option B - Edit detonatorapi/settings.py:")
print(f" CORS_ALLOW_ORIGINS = [ 'http://detonator.r00ted.ch' ] ")
print("\n" + "="*70 + "\n")
def refresh_files_from_disk():
# get a list of filenames of files in upload/
@@ -167,7 +146,6 @@ def main():
start_web = args.mode in ['both', 'web']
fastapi_thread = None
print_cors_help()
# check if we have a profile with data.edr_mde configured
db = get_db_direct()
+1 -12
View File
@@ -1,5 +1,4 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
import logging
from dotenv import load_dotenv
@@ -16,7 +15,7 @@ from .web_files import router as files_router
from .web_submissions import router as submissions_router
from .web_vms import router as vms_router
from .web_profiles import router as profiles_router
from .settings import CORS_ALLOW_ORIGINS, AUTH_PASSWORD
from .settings import AUTH_PASSWORD
from .utils import sanitize_runtime_seconds
from .edr_cloud.edr_cloud_manager import edr_cloud_plugins
from .web_files import MAX_FILE_SIZE
@@ -32,16 +31,6 @@ logger = logging.getLogger(__name__)
app = FastAPI(title="Detonator API", version="0.1.0")
# Add CORS middleware to allow requests from Flask frontend
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ALLOW_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup_event():
"""Initialize VM manager and start monitoring on startup"""
-4
View File
@@ -19,8 +19,4 @@ _settings = load_settings()
VM_DESTROY_AFTER = _settings.get("vm_destroy_after", 60) # minutes
AUTH_PASSWORD = _settings.get("auth_password", "")
CORS_ALLOW_ORIGINS = _settings.get(
"cors_allowed_origins",
"http://localhost:5000,http://127.0.0.1:5000"
).split(",")
DISABLE_REVERT_VM = _settings.get("disable_revert_vm", False)
-1
View File
@@ -1,5 +1,4 @@
auth_password: ""
cors_allowed_origins: http://localhost:5000,http://127.0.0.1:5000
vm_destroy_after: 60
disable_revert_vm: false
+21
View File
@@ -1,3 +1,23 @@
"""
Authentication module for Detonator API.
Two authentication paths are supported:
1. Browser → Flask (session cookie) → FastAPI (X-Auth-Password header)
- Flask validates the user's password, sets a signed session cookie.
- Flask proxies API calls to FastAPI, injecting X-Auth-Password with the
server-side AUTH_PASSWORD value.
- The browser never sees or stores the raw password.
2. curl / direct API → FastAPI (X-Auth-Password or Authorization header)
- The user provides the password directly via X-Auth-Password header,
Authorization: Bearer <password>, or Authorization: Basic <base64>.
- Useful for scripting and direct API access.
When AUTH_PASSWORD is empty/None, authentication is disabled and all
requests are treated as admin.
"""
from dataclasses import dataclass
from typing import Optional
from fastapi import Request, HTTPException
@@ -14,6 +34,7 @@ def check_password_auth(request: Request) -> bool:
return True
# Check for X-Auth-Password header
# As used by DetonatorUi
auth_password = request.headers.get("X-Auth-Password", "")
if hmac.compare_digest(auth_password, AUTH_PASSWORD):
return True
+23
View File
@@ -0,0 +1,23 @@
from flask import session
from detonatorapi.settings import AUTH_PASSWORD
def is_auth_enabled() -> bool:
"""Check if authentication is enabled (AUTH_PASSWORD is configured)."""
if AUTH_PASSWORD and AUTH_PASSWORD != "":
return True
else:
return False
def api_headers() -> dict:
"""Return headers for proxying requests to the FastAPI backend.
Only includes X-Auth-Password if the user session is authenticated.
Uses the server-side AUTH_PASSWORD, so the browser never sees it.
"""
headers = {}
if AUTH_PASSWORD and session.get("authenticated"):
headers["X-Auth-Password"] = AUTH_PASSWORD
return headers
+3
View File
@@ -1,6 +1,7 @@
import os
import yaml
import secrets
from pathlib import Path
@@ -16,3 +17,5 @@ def load_config():
_config = load_config()
API_BASE_URL = _config.get("api_base_url", "http://localhost:8000")
SECRET_KEY = _config.get("secret_key") or secrets.token_hex(32)
+4
View File
@@ -1 +1,5 @@
api_base_url: "http://localhost:8000"
# Flask session signing key. Change this in production.
# If not set, a random key is generated on each startup (invalidating existing sessions).
secret_key: ""
+41 -12
View File
@@ -1,19 +1,23 @@
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash, session
from functools import wraps
import requests
import os
import logging
import hmac
from datetime import datetime
from pathlib import Path
from .post import post_bp
from .get import get_bp
from .config import API_BASE_URL
from .config import API_BASE_URL, SECRET_KEY
from detonatorapi.settings import AUTH_PASSWORD
from detonatorapi.edr_cloud.elastic_rule_resolver import ElasticRuleResolver
from .auth import is_auth_enabled, api_headers
app = Flask(__name__)
app.secret_key = "detonator-secret-key" # Change this in production
app.secret_key = SECRET_KEY
app.config['MAX_CONTENT_LENGTH'] = 128 * 1024 * 1024 # 128 MB
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
@app.errorhandler(413)
@@ -35,20 +39,45 @@ csv_path = Path(__file__).parent.parent / "elastic_rules" / "elastic_rules.csv"
elastic_rule_resolver = ElasticRuleResolver(csv_path=str(csv_path))
# Make API_BASE_URL available to all templates
@app.context_processor
def inject_api_base_url():
# Check if authentication is enabled
auth_enabled = False
if AUTH_PASSWORD != None and AUTH_PASSWORD != "":
auth_enabled = True
def inject_template_globals():
return {
'API_BASE_URL': API_BASE_URL,
'AUTH_ENABLED': auth_enabled
'AUTH_ENABLED': is_auth_enabled(),
'IS_AUTHENTICATED': not is_auth_enabled() or session.get("authenticated", False),
}
# Authentication
@app.route("/login", methods=["POST"])
def login_post():
"""Validate password and set session cookie."""
password = request.form.get("password", "")
if not is_auth_enabled():
session["authenticated"] = True
return redirect(request.args.get("return_url", "/"))
if AUTH_PASSWORD and hmac.compare_digest(password, AUTH_PASSWORD):
session["authenticated"] = True
session.permanent = True
return redirect(request.args.get("return_url") or "/")
flash("Invalid password. Please try again.", "error")
return redirect(url_for("get.login_page", return_url=request.args.get("return_url")))
@app.route("/logout")
def logout():
"""Clear the session and redirect to login page."""
session.clear()
if is_auth_enabled():
return redirect(url_for("get.login_page"))
return redirect(url_for("get.index"))
# Helper function for Jinja2 templates
def get_status_color(status):
"""Get CSS classes for status badges"""
+24 -31
View File
@@ -2,23 +2,17 @@ from flask import Blueprint, render_template, request, jsonify, redirect, url_f
from typing import Optional, Dict
import requests
import logging
from .config import API_BASE_URL
import json
import logging
from .config import API_BASE_URL
from .auth import api_headers, is_auth_enabled
get_bp = Blueprint('get', __name__)
logger = logging.getLogger(__name__)
def _auth_headers() -> Dict[str, str]:
headers: Dict[str, str] = {}
password = request.headers.get("X-Auth-Password")
if password:
headers["X-Auth-Password"] = password
return headers
# MAIN Pages
@get_bp.route("/")
@@ -31,7 +25,6 @@ def login_page():
@get_bp.route("/logout")
def logout_page():
# This will be handled by JavaScript to clear localStorage
return render_template("logout.html")
@get_bp.route("/files")
@@ -43,7 +36,7 @@ def create_file_submission_page(file_id):
"""Page to create a submission for a specific file"""
try:
# Fetch file details
file_response = requests.get(f"{API_BASE_URL}/api/files/{file_id}", headers=_auth_headers())
file_response = requests.get(f"{API_BASE_URL}/api/files/{file_id}", headers=api_headers())
if file_response.status_code == 200:
file_data = file_response.json()
else:
@@ -52,7 +45,7 @@ def create_file_submission_page(file_id):
return redirect(url_for('get.files_page'))
# Fetch profiles
profiles_response = requests.get(f"{API_BASE_URL}/api/profiles", headers=_auth_headers())
profiles_response = requests.get(f"{API_BASE_URL}/api/profiles", headers=api_headers())
if profiles_response.status_code == 200:
profiles = profiles_response.json()
else:
@@ -70,7 +63,7 @@ def edit_file_page(file_id):
"""Page to edit a file's metadata"""
try:
# Fetch file details
file_response = requests.get(f"{API_BASE_URL}/api/files/{file_id}", headers=_auth_headers())
file_response = requests.get(f"{API_BASE_URL}/api/files/{file_id}", headers=api_headers())
if file_response.status_code == 200:
file_data = file_response.json()
else:
@@ -92,7 +85,7 @@ def submissions_page():
def submission_detail_page(submission_id):
"""Page to display details of a specific submission"""
try:
response = requests.get(f"{API_BASE_URL}/api/submissions/{submission_id}", headers=_auth_headers())
response = requests.get(f"{API_BASE_URL}/api/submissions/{submission_id}", headers=api_headers())
if response.status_code == 200:
submission = response.json()
else:
@@ -108,7 +101,7 @@ def submission_detail_page(submission_id):
def submission_page():
# Fetch profiles list
try:
response = requests.get(f"{API_BASE_URL}/api/profiles", headers=_auth_headers())
response = requests.get(f"{API_BASE_URL}/api/profiles", headers=api_headers())
if response.status_code == 200:
profiles = response.json()
else:
@@ -137,7 +130,7 @@ def view_profile_page(profile_id):
"""Page to view a specific profile (read-only)"""
try:
# Fetch the profile data
response = requests.get(f"{API_BASE_URL}/api/profiles/{profile_id}", headers=_auth_headers())
response = requests.get(f"{API_BASE_URL}/api/profiles/{profile_id}", headers=api_headers())
if response.status_code == 200:
profile = response.json()
else:
@@ -146,7 +139,7 @@ def view_profile_page(profile_id):
return redirect(url_for('get.profiles_page'))
# Fetch available connectors
connectors_response = requests.get(f"{API_BASE_URL}/api/connectors", headers=_auth_headers())
connectors_response = requests.get(f"{API_BASE_URL}/api/connectors", headers=api_headers())
if connectors_response.status_code == 200:
connectors = connectors_response.json()
else:
@@ -168,7 +161,7 @@ def edit_profile_page(profile_id):
"""Page to edit a specific profile"""
try:
# Fetch the profile data
response = requests.get(f"{API_BASE_URL}/api/profiles/{profile_id}", headers=_auth_headers())
response = requests.get(f"{API_BASE_URL}/api/profiles/{profile_id}", headers=api_headers())
if response.status_code == 200:
profile = response.json()
else:
@@ -177,7 +170,7 @@ def edit_profile_page(profile_id):
return redirect(url_for('get.profiles_page'))
# Fetch available connectors
connectors_response = requests.get(f"{API_BASE_URL}/api/connectors", headers=_auth_headers())
connectors_response = requests.get(f"{API_BASE_URL}/api/connectors", headers=api_headers())
if connectors_response.status_code == 200:
connectors = connectors_response.json()
else:
@@ -198,7 +191,7 @@ def create_profile_page():
"""Page to create a new profile"""
try:
# Fetch available connectors
connectors_response = requests.get(f"{API_BASE_URL}/api/connectors", headers=_auth_headers())
connectors_response = requests.get(f"{API_BASE_URL}/api/connectors", headers=api_headers())
if connectors_response.status_code == 200:
connectors = connectors_response.json()
else:
@@ -244,7 +237,7 @@ def semidatasieve(submission_id):
def files_template():
"""Template endpoint to render files list via HTMX"""
try:
response = requests.get(f"{API_BASE_URL}/api/files", headers=_auth_headers())
response = requests.get(f"{API_BASE_URL}/api/files", headers=api_headers())
if response.status_code == 200:
files = response.json()
# Sort submissions by ID in descending order (newest first)
@@ -296,7 +289,7 @@ def submissions_template():
if filter_status and filter_status != 'all':
params['status'] = filter_status
response = requests.get(f"{API_BASE_URL}/api/submissions", params=params, headers=_auth_headers())
response = requests.get(f"{API_BASE_URL}/api/submissions", params=params, headers=api_headers())
if response.status_code == 200:
submissions = response.json()
else:
@@ -311,7 +304,7 @@ def submission_details_template(submission_id):
"""Template endpoint to render submission details via HTMX"""
submission: Optional[Dict] = {}
try:
response = requests.get(f"{API_BASE_URL}/api/submissions/{submission_id}", headers=_auth_headers())
response = requests.get(f"{API_BASE_URL}/api/submissions/{submission_id}", headers=api_headers())
if response.status_code == 200:
submission = response.json()
else:
@@ -326,7 +319,7 @@ def submission_details_template(submission_id):
def vms_template():
"""Template endpoint to render VMs list via HTMX"""
try:
response = requests.get(f"{API_BASE_URL}/api/vms", headers=_auth_headers())
response = requests.get(f"{API_BASE_URL}/api/vms", headers=api_headers())
if response.status_code == 200:
vms = response.json()
vms = sorted(vms, key=lambda vm: vm['name'])
@@ -341,7 +334,7 @@ def vms_template():
def profiles_template():
"""Template endpoint to render profiles list via HTMX"""
try:
response = requests.get(f"{API_BASE_URL}/api/profiles", headers=_auth_headers())
response = requests.get(f"{API_BASE_URL}/api/profiles", headers=api_headers())
if response.status_code == 200:
profiles = response.json()
@@ -349,7 +342,7 @@ def profiles_template():
for profile_name, profile in profiles.items():
url = f"{API_BASE_URL}/api/profiles/{profile['id']}/status"
try:
status_response = requests.get(url, headers=_auth_headers())
status_response = requests.get(url, headers=api_headers())
if status_response.status_code == 200:
status_data = status_response.json()
profile['agent_alive'] = status_data.get('agent_alive', False)
@@ -377,7 +370,7 @@ def profiles_template():
def profiles_overview_template():
"""Template endpoint to render profiles overview for index page via HTMX"""
try:
response = requests.get(f"{API_BASE_URL}/api/profiles")
response = requests.get(f"{API_BASE_URL}/api/profiles", headers=api_headers())
if response.status_code == 200:
templates = response.json()
# Add the name to each template for easier access in templates
@@ -430,7 +423,7 @@ def submissions_table_template():
if filter_status and filter_status != 'all':
params['status'] = filter_status
response = requests.get(f"{API_BASE_URL}/api/submissions", params=params)
response = requests.get(f"{API_BASE_URL}/api/submissions", params=params, headers=api_headers())
if response.status_code == 200:
submissions = response.json()
else:
@@ -446,14 +439,14 @@ def create_file_submission_template(file_id):
"""Template endpoint to render submission creation form via HTMX"""
try:
# Fetch file details
file_response = requests.get(f"{API_BASE_URL}/api/files/{file_id}")
file_response = requests.get(f"{API_BASE_URL}/api/files/{file_id}", headers=api_headers())
if file_response.status_code == 200:
file_data = file_response.json()
else:
file_data = None
# Fetch profiles
profiles_response = requests.get(f"{API_BASE_URL}/api/profiles")
profiles_response = requests.get(f"{API_BASE_URL}/api/profiles", headers=api_headers())
if profiles_response.status_code == 200:
profiles = profiles_response.json()
else:
@@ -469,7 +462,7 @@ def create_file_submission_template(file_id):
def get_connector_info(connector_name):
"""Get connector information for display"""
try:
response = requests.get(f"{API_BASE_URL}/api/connectors", headers=_auth_headers())
response = requests.get(f"{API_BASE_URL}/api/connectors", headers=api_headers())
if response.status_code == 200:
connectors = response.json()
if connector_name in connectors:
+126 -73
View File
@@ -1,8 +1,10 @@
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, session, redirect, url_for
import requests
import logging
import json
from .config import API_BASE_URL
from .auth import api_headers, is_auth_enabled
from detonatorapi.utils import (
filename_randomizer,
@@ -16,6 +18,13 @@ logger = logging.getLogger(__name__)
post_bp = Blueprint('post', __name__)
@post_bp.before_request
def require_auth_for_post():
"""Require authentication for all POST routes in this blueprint."""
if is_auth_enabled() and not session.get("authenticated"):
return jsonify({"error": "Authentication required. Please log in.", "auth_required": True}), 401
def handle_api_response(response, operation_name="operation"):
"""Helper function to handle API responses consistently"""
if response.status_code == 200:
@@ -45,50 +54,43 @@ def create_submission():
try:
files = {}
data = {}
# Forward authentication header from request
headers = {}
if 'X-Auth-Password' in request.headers:
headers['X-Auth-Password'] = request.headers.get('X-Auth-Password')
elif 'Authorization' in request.headers:
headers['Authorization'] = request.headers.get('Authorization')
if 'file' in request.files:
# fix filename handling
uploaded_file = request.files['file']
filename = uploaded_file.filename
# Check if filename randomization is enabled
randomize = request.form.get('randomize_filename') == 'on'
if randomize and filename:
filename = filename_randomizer(filename)
files['file'] = (filename, uploaded_file.stream, uploaded_file.content_type)
if 'source_url' in request.form:
data['source_url'] = request.form['source_url']
if 'file_comment' in request.form:
data['file_comment'] = request.form['file_comment']
if 'submission_comment' in request.form:
data['submission_comment'] = request.form['submission_comment']
if 'exec_arguments' in request.form:
data['exec_arguments'] = request.form['exec_arguments']
if 'project' in request.form:
data['project'] = request.form['project']
if 'profile_name' in request.form:
data['profile_name'] = request.form['profile_name']
if 'password' in request.form:
data['password'] = request.form['password']
if 'token' in request.form:
data['token'] = request.form['token']
if 'runtime' in request.form:
raw_runtime = request.form['runtime'].strip()
if raw_runtime:
@@ -101,14 +103,14 @@ def create_submission():
"error": f"Runtime must be between {RUNTIME_MIN_SECONDS} and {RUNTIME_MAX_SECONDS} seconds"
}, 400
data['runtime'] = runtime_value
if 'drop_path' in request.form:
data['drop_path'] = request.form['drop_path']
if 'execution_mode' in request.form:
data['execution_mode'] = request.form['execution_mode']
response = requests.post(f"{API_BASE_URL}/api/create-submission", files=files, data=data, headers=headers)
response = requests.post(f"{API_BASE_URL}/api/create-submission", files=files, data=data, headers=api_headers())
return handle_api_response(response, "file upload and submission")
except requests.RequestException as e:
return {"error": f"Could not upload file: {str(e)}"}, 500
@@ -118,16 +120,9 @@ def create_submission():
def create_profile():
"""Proxy endpoint to create a profile via FastAPI"""
try:
# Forward authentication header from request
headers = {}
if 'X-Auth-Password' in request.headers:
headers['X-Auth-Password'] = request.headers.get('X-Auth-Password')
elif 'Authorization' in request.headers:
headers['Authorization'] = request.headers.get('Authorization')
# Prepare form data
data = {}
# Required fields
if 'name' in request.form:
data['name'] = request.form['name']
@@ -139,7 +134,7 @@ def create_profile():
data['port'] = request.form['port']
if 'data' in request.form:
data['data'] = request.form['data']
# Optional fields
if 'default_drop_path' in request.form:
data['default_drop_path'] = request.form['default_drop_path']
@@ -149,23 +144,23 @@ def create_profile():
data['password'] = request.form['password']
if 'rededr_port' in request.form:
data['rededr_port'] = request.form['rededr_port']
# Send POST request to FastAPI
response = requests.post(
f"{API_BASE_URL}/api/profiles",
data=data,
headers=headers
f"{API_BASE_URL}/api/profiles",
data=data,
headers=api_headers()
)
result = handle_api_response(response, "profile creation")
# If handle_api_response returned a tuple (error case), return it
if isinstance(result, tuple):
return result
# Success case - return success message
return jsonify({"message": "Profile created successfully", "profile": result}), 200
except requests.RequestException as e:
logger.exception(f"Exception while creating profile: {e}")
return jsonify({"error": f"Could not create profile: {str(e)}"}), 500
@@ -175,16 +170,9 @@ def create_profile():
def update_profile(profile_id):
"""Proxy endpoint to update a profile via FastAPI"""
try:
# Forward authentication header from request
headers = {}
if 'X-Auth-Password' in request.headers:
headers['X-Auth-Password'] = request.headers.get('X-Auth-Password')
elif 'Authorization' in request.headers:
headers['Authorization'] = request.headers.get('Authorization')
# Prepare form data
data = {}
# Required fields
if 'name' in request.form:
data['name'] = request.form['name']
@@ -196,7 +184,7 @@ def update_profile(profile_id):
data['port'] = request.form['port']
if 'data' in request.form:
data['data'] = request.form['data']
# Optional fields
if 'default_drop_path' in request.form:
data['default_drop_path'] = request.form['default_drop_path']
@@ -206,23 +194,23 @@ def update_profile(profile_id):
data['password'] = request.form['password']
if 'rededr_port' in request.form:
data['rededr_port'] = request.form['rededr_port']
# Send PUT request to FastAPI
response = requests.put(
f"{API_BASE_URL}/api/profiles/{profile_id}",
data=data,
headers=headers
f"{API_BASE_URL}/api/profiles/{profile_id}",
data=data,
headers=api_headers()
)
result = handle_api_response(response, "profile update")
# If handle_api_response returned a tuple (error case), return it
if isinstance(result, tuple):
return result
# Success case - return success message
return jsonify({"message": "Profile updated successfully", "profile": result}), 200
except requests.RequestException as e:
logger.exception(f"Exception while updating profile {profile_id}: {e}")
return jsonify({"error": f"Could not update profile: {str(e)}"}), 500
@@ -232,39 +220,104 @@ def update_profile(profile_id):
def update_file(file_id):
"""Proxy endpoint to update a file's metadata via FastAPI"""
try:
# Forward authentication header from request
headers = {}
if 'X-Auth-Password' in request.headers:
headers['X-Auth-Password'] = request.headers.get('X-Auth-Password')
elif 'Authorization' in request.headers:
headers['Authorization'] = request.headers.get('Authorization')
# Prepare form data
data = {}
if 'source_url' in request.form:
data['source_url'] = request.form['source_url']
if 'comment' in request.form:
data['comment'] = request.form['comment']
if 'exec_arguments' in request.form:
data['exec_arguments'] = request.form['exec_arguments']
# Send PUT request to FastAPI
response = requests.put(
f"{API_BASE_URL}/api/files/{file_id}",
data=data,
headers=headers
f"{API_BASE_URL}/api/files/{file_id}",
data=data,
headers=api_headers()
)
result = handle_api_response(response, "file update")
# If handle_api_response returned a tuple (error case), return it
if isinstance(result, tuple):
return result
# Success case - return success message
return jsonify({"message": "File updated successfully", "file": result}), 200
except requests.RequestException as e:
logger.exception(f"Exception while updating file {file_id}: {e}")
return jsonify({"error": f"Could not update file: {str(e)}"}), 500
# --- Admin action proxies (called by templates via fetch) ---
@post_bp.route("/api/profiles/<int:profile_id>/delete", methods=["POST", "DELETE"])
def delete_profile(profile_id):
"""Proxy endpoint to delete a profile via FastAPI"""
try:
response = requests.delete(f"{API_BASE_URL}/api/profiles/{profile_id}", headers=api_headers())
result = handle_api_response(response, "profile deletion")
if isinstance(result, tuple):
return result
return jsonify(result), response.status_code
except requests.RequestException as e:
logger.exception(f"Exception while deleting profile {profile_id}: {e}")
return jsonify({"error": f"Could not delete profile: {str(e)}"}), 500
@post_bp.route("/api/profiles/<int:profile_id>/release_lock", methods=["POST"])
def release_lock(profile_id):
"""Proxy endpoint to release a profile lock via FastAPI"""
try:
response = requests.post(f"{API_BASE_URL}/api/profiles/{profile_id}/release_lock", headers=api_headers())
result = handle_api_response(response, "lock release")
if isinstance(result, tuple):
return result
return jsonify(result), response.status_code
except requests.RequestException as e:
logger.exception(f"Exception while releasing lock for profile {profile_id}: {e}")
return jsonify({"error": f"Could not release lock: {str(e)}"}), 500
@post_bp.route("/api/profiles/<int:profile_id>/reboot", methods=["POST"])
def reboot_profile(profile_id):
"""Proxy endpoint to reboot a profile's VM via FastAPI"""
try:
response = requests.post(f"{API_BASE_URL}/api/profiles/{profile_id}/reboot", headers=api_headers())
result = handle_api_response(response, "VM reboot")
if isinstance(result, tuple):
return result
return jsonify(result), response.status_code
except requests.RequestException as e:
logger.exception(f"Exception while rebooting profile {profile_id}: {e}")
return jsonify({"error": f"Could not reboot VM: {str(e)}"}), 500
@post_bp.route("/api/profiles/<int:profile_id>/revert", methods=["POST"])
def revert_profile(profile_id):
"""Proxy endpoint to revert a profile's VM via FastAPI"""
try:
response = requests.post(f"{API_BASE_URL}/api/profiles/{profile_id}/revert", headers=api_headers())
result = handle_api_response(response, "VM revert")
if isinstance(result, tuple):
return result
return jsonify(result), response.status_code
except requests.RequestException as e:
logger.exception(f"Exception while reverting profile {profile_id}: {e}")
return jsonify({"error": f"Could not revert VM: {str(e)}"}), 500
@post_bp.route("/api/submissions/<int:submission_id>/delete", methods=["POST", "DELETE"])
def delete_submission(submission_id):
"""Proxy endpoint to delete a submission via FastAPI"""
try:
response = requests.delete(f"{API_BASE_URL}/api/submissions/{submission_id}", headers=api_headers())
result = handle_api_response(response, "submission deletion")
if isinstance(result, tuple):
return result
return jsonify(result), response.status_code
except requests.RequestException as e:
logger.exception(f"Exception while deleting submission {submission_id}: {e}")
return jsonify({"error": f"Could not delete submission: {str(e)}"}), 500
+27 -40
View File
@@ -104,8 +104,20 @@
</a>
</div>
<!-- Logout button at bottom -->
<div class="p-4 border-t nav-border">
<!-- Login button at bottom (shown when not authenticated) -->
<div class="p-4 border-t nav-border" data-auth-hide>
<a href="/login" class="nav-item block px-6 py-3 text-white rounded">
<span class="flex items-center">
<svg class="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 3a1 1 0 011 1v12a1 1 0 11-2 0V4a1 1 0 011-1zm7.707 3.293a1 1 0 010 1.414L9.414 9H17a1 1 0 110 2H9.414l1.293 1.293a1 1 0 01-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
Login
</span>
</a>
</div>
<!-- Logout button at bottom (shown when authenticated) -->
<div class="p-4 border-t nav-border" data-auth-required>
<a href="/logout" class="nav-item block px-6 py-3 text-white rounded">
<span class="flex items-center">
<svg class="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
@@ -123,21 +135,15 @@
</main>
<script>
// Authentication helper functions
// Authentication helper - uses server-side session status
function isAuthenticated() {
// If no auth is configured, always return true
if (! {{ AUTH_ENABLED|lower }}) {
return true;
}
// Otherwise check for stored password
return !!localStorage.getItem('detonator_auth_password');
return {{ IS_AUTHENTICATED|lower }};
}
// Show/hide elements based on authentication status
function updateUIForAuth() {
const authenticated = isAuthenticated();
// Show/hide elements with data-auth-required attribute
document.querySelectorAll('[data-auth-required]').forEach(el => {
if (authenticated) {
@@ -148,7 +154,7 @@
el.classList.add('hidden');
}
});
// Hide elements with data-auth-hide attribute when authenticated
document.querySelectorAll('[data-auth-hide]').forEach(el => {
if (authenticated) {
@@ -163,45 +169,26 @@
// Run on page load
document.addEventListener('DOMContentLoaded', updateUIForAuth);
// Run after HTMX updates
document.body.addEventListener('htmx:afterSwap', updateUIForAuth);
document.body.addEventListener('htmx:afterSettle', updateUIForAuth);
// Authentication interceptor for HTMX requests
document.body.addEventListener('htmx:configRequest', function(evt) {
const password = localStorage.getItem('detonator_auth_password');
if (password) {
evt.detail.headers['X-Auth-Password'] = password;
}
});
// Handle 401 responses by redirecting to login
document.body.addEventListener('htmx:responseError', function(evt) {
if (evt.detail.xhr.status === 401) {
// Store current page to return after login
const returnUrl = encodeURIComponent(window.location.pathname);
window.location.href = '/login?return=' + returnUrl;
var returnUrl = encodeURIComponent(window.location.pathname);
window.location.href = '/login?return_url=' + returnUrl;
}
});
// Also handle regular fetch requests (non-HTMX)
const originalFetch = window.fetch;
window.fetch = function(...args) {
// Add auth header to fetch requests
const password = localStorage.getItem('detonator_auth_password');
if (password && args[1]) {
args[1].headers = args[1].headers || {};
args[1].headers['X-Auth-Password'] = password;
} else if (password && !args[1]) {
args[1] = { headers: { 'X-Auth-Password': password } };
}
return originalFetch.apply(this, args).then(response => {
// Redirect to login on 401
// Fetch interceptor: redirect to login on 401
var originalFetch = window.fetch;
window.fetch = function() {
return originalFetch.apply(this, arguments).then(function(response) {
if (response.status === 401) {
const returnUrl = encodeURIComponent(window.location.pathname);
window.location.href = '/login?return=' + returnUrl;
var returnUrl = encodeURIComponent(window.location.pathname);
window.location.href = '/login?return_url=' + returnUrl;
}
return response;
});
-26
View File
@@ -45,30 +45,4 @@
</div>
</div>
</div>
<script>
// Check API status on page load
window.addEventListener('DOMContentLoaded', function() {
const apiUrl = '{{ API_BASE_URL }}';
const checkingDiv = document.getElementById('api-status-checking');
const errorDiv = document.getElementById('api-status-error');
const urlDisplay = document.getElementById('api-url-display');
fetch(apiUrl + '/api/health', {
method: 'GET',
mode: 'cors',
cache: 'no-cache'
})
.then(response => {
checkingDiv.classList.add('hidden');
if (! response.ok) {
errorDiv.classList.remove('hidden');
}
})
.catch(error => {
checkingDiv.classList.add('hidden');
errorDiv.classList.remove('hidden');
});
});
</script>
{% endblock %}
+25 -79
View File
@@ -18,104 +18,50 @@
<p class="text-secondary">Enter password to access the system</p>
</div>
<form id="loginForm" class="space-y-6">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
<div class="flex items-center">
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
<span>{{ message }}</span>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('login_post', return_url=request.args.get('return_url', '')) }}" class="space-y-6">
<div>
<label for="password" class="block text-sm font-medium text-label mb-2">
Password
</label>
<input
type="password"
id="password"
name="password"
<input
type="password"
id="password"
name="password"
required
autofocus
class="w-full px-4 py-3 border border-divider rounded-lg focus:outline-none focus:ring-2 input-focus-ring focus:border-transparent"
placeholder="Enter your password"
>
</div>
<div id="error-message" class="hidden bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
<div class="flex items-center">
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
<span id="error-text">Invalid password</span>
</div>
</div>
<button
type="submit"
<button
type="submit"
class="btn-primary w-full font-semibold py-3 px-4 transform hover:scale-105"
>
Login
</button>
</form>
<div class="mt-6 pt-6 border-t border-card">
<p class="text-sm text-secondary text-center">
<strong>Note:</strong> Password is stored locally in your browser
Login is handled with a secure session cookie. No password is stored in the browser.
</p>
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const password = document.getElementById('password').value;
const errorDiv = document.getElementById('error-message');
const errorText = document.getElementById('error-text');
// Hide error message
errorDiv.classList.add('hidden');
try {
// Test the password by making a simple API call
const response = await fetch('{{ API_BASE_URL }}/api/health', {
method: 'GET',
headers: {
'X-Auth-Password': password
}
});
if (response.ok) {
// Store password in localStorage
localStorage.setItem('detonator_auth_password', password);
// Redirect to home page or return URL
const returnUrl = new URLSearchParams(window.location.search).get('return') || '/';
window.location.href = returnUrl;
} else {
// Show error
errorText.textContent = 'Invalid password. Please try again.';
errorDiv.classList.remove('hidden');
document.getElementById('password').value = '';
document.getElementById('password').focus();
}
} catch (error) {
errorText.textContent = 'Connection error. Please check if the API server is running.';
errorDiv.classList.remove('hidden');
}
});
// Check if already logged in
window.addEventListener('DOMContentLoaded', function() {
const password = localStorage.getItem('detonator_auth_password');
if (password) {
// Already have a password, verify it and redirect
fetch('{{ API_BASE_URL }}/api/health', {
method: 'GET',
headers: {
'X-Auth-Password': password
}
}).then(response => {
if (response.ok) {
const returnUrl = new URLSearchParams(window.location.search).get('return') || '/';
window.location.href = returnUrl;
}
});
}
});
</script>
</body>
</html>
+4 -9
View File
@@ -18,21 +18,16 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<h1 class="text-2xl font-bold text-heading mb-2">Logged Out</h1>
<p class="text-secondary mb-6">You have been successfully logged out.</p>
<a
href="/login"
<a
href="/login"
class="btn-info inline-block font-semibold py-3 px-6"
>
Login Again
</a>
</div>
<script>
// Clear password from localStorage
localStorage.removeItem('detonator_auth_password');
</script>
</body>
</html>
+3 -3
View File
@@ -396,11 +396,11 @@
}
// Check authentication and set runtime field accordingly
const password = localStorage.getItem('auth_password');
const isAuth = {{ IS_AUTHENTICATED|lower }};
const runtimeField = document.getElementById('runtime');
const runtimeDescription = document.getElementById('runtime_description');
if (!password) {
if (!isAuth) {
// User is not authenticated - set runtime to 12 and make read-only
runtimeField.value = 12;
runtimeField.readOnly = true;
@@ -140,10 +140,7 @@
function downloadFile(fileId, filename) {
fetch('{{ API_BASE_URL }}/api/files/' + fileId + '/download', {
method: 'POST',
headers: {
'X-Auth-Password': localStorage.getItem('authToken') || ''
}
method: 'POST'
})
.then(response => {
if (!response.ok) {
+5 -5
View File
@@ -58,8 +58,8 @@ document.head.appendChild(style);
function deleteProfile(profileId, profileName) {
if (confirm(`Are you sure you want to delete profile "${profileName}"?`)) {
fetch(`{{ API_BASE_URL }}/api/profiles/${profileId}`, {
method: 'DELETE'
fetch(`/api/profiles/${profileId}/delete`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
@@ -89,7 +89,7 @@ function deleteProfile(profileId, profileName) {
function releaseLock(profileId, profileName) {
if (confirm(`Are you sure you want to release the lock for profile "${profileName}"?`)) {
fetch(`{{ API_BASE_URL }}/api/profiles/${profileId}/release_lock`, {
fetch(`/api/profiles/${profileId}/release_lock`, {
method: 'POST'
})
.then(response => response.json())
@@ -120,7 +120,7 @@ function releaseLock(profileId, profileName) {
function rebootProfile(profileId, profileName) {
if (confirm(`Are you sure you want to reboot the VM for profile "${profileName}"?`)) {
fetch(`{{ API_BASE_URL }}/api/profiles/${profileId}/reboot`, {
fetch(`/api/profiles/${profileId}/reboot`, {
method: 'POST'
})
.then(response => response.json())
@@ -160,7 +160,7 @@ function revertProfile(profileId, profileName) {
processingMsg.innerHTML = '<div class="flex items-center"><div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 spinner-border mr-2"></div>Reverting VM... This may take a minute.</div>';
document.body.appendChild(processingMsg);
fetch(`{{ API_BASE_URL }}/api/profiles/${profileId}/revert`, {
fetch(`/api/profiles/${profileId}/revert`, {
method: 'POST'
})
.then(response => response.json())
+2 -2
View File
@@ -196,8 +196,8 @@
function deleteSubmission(submissionId) {
if (confirm('Are you sure you want to delete this submission? This action cannot be undone.')) {
fetch('{{ API_BASE_URL }}/api/submissions/' + submissionId, {
method: 'DELETE'
fetch('/api/submissions/' + submissionId + '/delete', {
method: 'POST'
})
.then(response => response.json())
.then(data => {