From d8b0889d7d7d100652d53538f3fae51d2b75400a Mon Sep 17 00:00:00 2001 From: Sangye Ince-Johannsen <sij@sij.law> Date: Sun, 6 Apr 2025 21:05:05 +0000 Subject: [PATCH] Updates --- canary.sh | 20 +++- sw1tch/__main__.py | 10 +- sw1tch/canary.py | 92 ++++++++++------- sw1tch/routes/admin.py | 84 ++++++++++++++-- sw1tch/routes/canary.py | 85 +++++++++++----- sw1tch/static/styles.css | 82 ++++++++++++++- sw1tch/templates/admin.html | 99 +++++++++++++++++++ sw1tch/templates/canary.html | 32 ++++++ sw1tch/templates/canary_preview.html | 29 ++++-- sw1tch/templates/canary_success.html | 34 +++++-- sw1tch/templates/undocumented_users.html | 21 ++++ .../templates/unfulfilled_registrations.html | 27 +++++ 12 files changed, 513 insertions(+), 102 deletions(-) mode change 100755 => 100644 sw1tch/canary.py create mode 100644 sw1tch/templates/admin.html create mode 100644 sw1tch/templates/canary.html create mode 100644 sw1tch/templates/undocumented_users.html create mode 100644 sw1tch/templates/unfulfilled_registrations.html diff --git a/canary.sh b/canary.sh index a5f349f..781c316 100755 --- a/canary.sh +++ b/canary.sh @@ -1,4 +1,16 @@ -python canary.py -git add - A . -git commit -m "New canary" -git push origin main +#!/bin/bash + +# Run the canary script +python3 sw1tch/canary.py + +# Check if the canary.txt was generated successfully +if [ $? -eq 0 ] && [ -f sw1tch/data/canary.txt ]; then + # Stage all changes, commit, and push + git add sw1tch/data/canary.txt + git commit -m "Update warrant canary - $(date +%Y-%m-%d)" + git push origin main + echo "Warrant canary updated and pushed to repository." +else + echo "Failed to generate or find canary.txt. Git operations aborted." + exit 1 +fi diff --git a/sw1tch/__main__.py b/sw1tch/__main__.py index 1ee97b8..6d2ffee 100644 --- a/sw1tch/__main__.py +++ b/sw1tch/__main__.py @@ -2,16 +2,14 @@ import os from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from sw1tch import BASE_DIR, CustomLoggingMiddleware -from sw1tch.routes.public import router as public_router -from sw1tch.routes.admin import router as admin_router -from sw1tch.routes.canary import router as canary_router +from sw1tch.routes import admin, canary, public app = FastAPI() app.add_middleware(CustomLoggingMiddleware) app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static") -app.include_router(public_router) -app.include_router(admin_router) -app.include_router(canary_router) + +app.include_router(admin.router) +app.include_router(public.router) if __name__ == "__main__": import uvicorn diff --git a/sw1tch/canary.py b/sw1tch/canary.py old mode 100755 new mode 100644 index 3ed5fef..d8c2465 --- a/sw1tch/canary.py +++ b/sw1tch/canary.py @@ -12,32 +12,52 @@ from pathlib import Path from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -# File paths -CONFIG_FILE = "config.yaml" -OUTPUT_FILE = "canary.txt" -TEMP_MESSAGE_FILE = "temp_canary_message.txt" +# File paths relative to the sw1tch/ directory +BASE_DIR = Path(__file__).parent +CONFIG_FILE = BASE_DIR / "config" / "config.yaml" +ATTESTATIONS_FILE = BASE_DIR / "config" / "attestations.txt" +OUTPUT_FILE = BASE_DIR / "data" / "canary.txt" +TEMP_MESSAGE_FILE = BASE_DIR / "data" / "temp_canary_message.txt" def load_config(): """Load configuration from YAML file.""" try: - if not os.path.exists(CONFIG_FILE): + if not CONFIG_FILE.exists(): print(f"Error: Configuration file '{CONFIG_FILE}' not found.") sys.exit(1) with open(CONFIG_FILE, 'r') as file: config = yaml.safe_load(file) - required = [('gpg', 'key_id'), ('canary', 'organization'), ('canary', 'attestations')] - for section, field in required: - if section not in config or field not in config[section]: - print(f"Error: Missing required field '{section}.{field}' in config.") - sys.exit(1) + # Adjust to match config.yaml structure + required = [ + ('canary', 'organization'), + ('canary', 'gpg_key_id'), + ('canary', 'credentials', 'username'), + ('canary', 'credentials', 'password'), + ('canary', 'room') + ] + for path in required: + current = config + for key in path: + if key not in current: + print(f"Error: Missing required field '{'.'.join(path)}' in config.") + sys.exit(1) + current = current[key] return config except Exception as e: print(f"Error loading configuration: {e}") sys.exit(1) -def get_current_date(): - """Return the current date in YYYY-MM-DD format.""" - return datetime.datetime.now().strftime("%Y-%m-%d") +def load_attestations(): + """Load attestations from attestations.txt.""" + try: + if not ATTESTATIONS_FILE.exists(): + print(f"Error: Attestations file '{ATTESTATIONS_FILE}' not found.") + sys.exit(1) + with open(ATTESTATIONS_FILE, 'r') as f: + return [line.strip() for line in f if line.strip()] + except Exception as e: + print(f"Error loading attestations: {e}") + sys.exit(1) def get_nist_time(): """Get the current time from NIST or fallback servers.""" @@ -66,7 +86,7 @@ def get_nist_time(): def get_rss_headline(config): """Get the latest headline and link from the configured RSS feed.""" try: - rss_config = config.get('rss', {}) + rss_config = config['canary'].get('rss', {}) rss_url = rss_config.get('url', 'https://www.democracynow.org/democracynow.rss') feed = feedparser.parse(rss_url) if feed.entries and len(feed.entries) > 0: @@ -100,11 +120,12 @@ def get_bitcoin_latest_block(): return None def collect_attestations(config): - """Prompt user for each attestation from config.""" + """Prompt user for each attestation from attestations.txt.""" + attestations = load_attestations() selected_attestations = [] org = config['canary']['organization'] print("\nPlease confirm each attestation separately:") - for i, attestation in enumerate(config['canary']['attestations'], 1): + for i, attestation in enumerate(attestations, 1): while True: response = input(f"Confirm: '{org} {attestation}' (y/n): ").lower() if response in ['y', 'n']: @@ -121,7 +142,6 @@ def get_optional_note(): def create_warrant_canary_message(config): """Create the warrant canary message with updated formatting.""" - current_date = get_current_date() nist_time = get_nist_time() rss_data = get_rss_headline(config) bitcoin_block = get_bitcoin_latest_block() @@ -129,7 +149,7 @@ def create_warrant_canary_message(config): if not all([nist_time, rss_data, bitcoin_block]): missing = [] if not nist_time: missing.append("NIST time") - if not rss_data: missing.append(f"{config['rss'].get('name', 'RSS')} headline") + if not rss_data: missing.append(f"{config['canary']['rss'].get('name', 'RSS')} headline") if not bitcoin_block: missing.append("Bitcoin block data") print(f"Error: Could not fetch: {', '.join(missing)}") return None @@ -145,9 +165,7 @@ def create_warrant_canary_message(config): org = config['canary']['organization'] admin_name = config['canary'].get('admin_name', 'Admin') admin_title = config['canary'].get('admin_title', 'administrator') - rss_name = config['rss'].get('name', 'RSS Feed') - # No leading \n; GPG adds one blank line after Hash: SHA512 message = f"{org} Warrant Canary · {nist_time}\n" message += f"I, {admin_name}, the {admin_title} of {org}, state this {datetime.datetime.now().strftime('%dth day of %B, %Y')}:\n" for i, attestation in enumerate(attestations, 1): @@ -162,12 +180,12 @@ def create_warrant_canary_message(config): message += f" BTC block: #{bitcoin_block['height']}, {bitcoin_block['time']}\n" message += f" Block hash: {bitcoin_block['hash']}\n" - return message.rstrip() + "\n" # Single newline before signature + return message.rstrip() + "\n" def sign_with_gpg(message, gpg_key_id): - """Sign the warrant canary message with GPG, ensuring no extra newline after signature header.""" + """Sign the warrant canary message with GPG.""" try: - with open(TEMP_MESSAGE_FILE, "w", newline='\n') as f: # Unix line endings + with open(TEMP_MESSAGE_FILE, "w", newline='\n') as f: f.write(message) cmd = ["gpg", "--clearsign", "--default-key", gpg_key_id, TEMP_MESSAGE_FILE] subprocess.run(cmd, check=True) @@ -175,13 +193,11 @@ def sign_with_gpg(message, gpg_key_id): signed_message = f.read() os.remove(TEMP_MESSAGE_FILE) os.remove(f"{TEMP_MESSAGE_FILE}.asc") - # Fix GPG's extra newline after -----BEGIN PGP SIGNATURE----- lines = signed_message.splitlines() signature_idx = next(i for i, line in enumerate(lines) if line == "-----BEGIN PGP SIGNATURE-----") if lines[signature_idx + 1] == "": - lines.pop(signature_idx + 1) # Remove blank line - signed_message = "\n".join(lines) - return signed_message + lines.pop(signature_idx + 1) + return "\n".join(lines) except subprocess.CalledProcessError as e: print(f"GPG signing error: {e}") return None @@ -202,13 +218,10 @@ def save_warrant_canary(signed_message): async def post_to_matrix(config, signed_message): """Post the signed warrant canary to Matrix room.""" - if not config.get('matrix', {}).get('enabled', False): - print("Matrix posting is disabled in config") - return False try: - from nio import AsyncClient, LoginResponse - matrix = config['matrix'] - client = AsyncClient(matrix['homeserver'], matrix['username']) + from nio import AsyncClient + matrix = config['canary']['credentials'] + client = AsyncClient(config['base_url'], matrix['username']) await client.login(matrix['password']) full_message = ( @@ -227,7 +240,7 @@ async def post_to_matrix(config, signed_message): f"<pre>{signed_message}</pre>" ) } - await client.room_send(matrix['room_id'], "m.room.message", content) + await client.room_send(config['canary']['room'], "m.room.message", content) await client.logout() await client.close() print("Posted to Matrix successfully") @@ -253,14 +266,17 @@ def main(): print("Operation cancelled") sys.exit(0) - signed_message = sign_with_gpg(message, config['gpg']['key_id']) + signed_message = sign_with_gpg(message, config['canary']['gpg_key_id']) if not signed_message: print("Failed to sign message") sys.exit(1) - if save_warrant_canary(signed_message) and config.get('matrix', {}).get('enabled', False): - if input("Post to Matrix? (y/n): ").lower() == 'y': - asyncio.run(post_to_matrix(config, signed_message)) + if not save_warrant_canary(signed_message): + print("Failed to save canary") + sys.exit(1) + + if input("Post to Matrix? (y/n): ").lower() == 'y': + asyncio.run(post_to_matrix(config, signed_message)) if __name__ == "__main__": main() diff --git a/sw1tch/routes/admin.py b/sw1tch/routes/admin.py index b967258..761d4a3 100644 --- a/sw1tch/routes/admin.py +++ b/sw1tch/routes/admin.py @@ -1,16 +1,73 @@ -from fastapi import APIRouter, Form, Depends -from fastapi.responses import JSONResponse +from fastapi import APIRouter, Form, Depends, Request, HTTPException +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.templating import Jinja2Templates from datetime import datetime, timedelta import httpx import re +import os +import hashlib -from sw1tch import config, logger, load_registrations, save_registrations, verify_admin_auth +from sw1tch import BASE_DIR, config, logger, load_registrations, save_registrations, verify_admin_auth from sw1tch.utilities.matrix import get_matrix_users, deactivate_user -router = APIRouter(prefix="/_admin", dependencies=[Depends(verify_admin_auth)]) +router = APIRouter(prefix="/_admin") +templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) + +@router.get("/", response_class=HTMLResponse) +async def admin_panel(request: Request, auth_token: str = Depends(verify_admin_auth)): + return templates.TemplateResponse("admin.html", {"request": request, "authenticated": True}) + +@router.get("/login", response_class=HTMLResponse) +async def admin_login_page(request: Request): + return templates.TemplateResponse("admin.html", {"request": request, "authenticated": False}) + +@router.post("/login", response_class=HTMLResponse) +async def admin_login(request: Request, password: str = Form(...)): + expected_password = config["matrix_admin"].get("password", "") + hashed_password = hashlib.sha256(password.encode()).hexdigest() + if hashed_password == hashlib.sha256(expected_password.encode()).hexdigest(): + return HTMLResponse( + content=f""" + <html> + <head> + <meta http-equiv="refresh" content="0;url=/_admin/?auth_token={hashed_password}"> + </head> + <body> + <p>Redirecting to admin panel...</p> + </body> + </html> + """ + ) + else: + return templates.TemplateResponse("admin.html", {"request": request, "authenticated": False, "error": "Invalid password"}) + +@router.get("/view_unfulfilled", response_class=HTMLResponse) +async def view_unfulfilled_registrations(request: Request, auth_token: str = Depends(verify_admin_auth)): + registrations = load_registrations() + unfulfilled = [] + if registrations: + current_time = datetime.utcnow() + async with httpx.AsyncClient() as client: + for entry in registrations: + username = entry["requested_name"] + reg_date = datetime.fromisoformat(entry["datetime"]) + age = current_time - reg_date + url = f"{config['base_url']}/_matrix/client/v3/register/available?username={username}" + try: + response = await client.get(url, timeout=5) + if response.status_code == 200 and response.json().get("available", False): + unfulfilled.append({ + "username": username, + "email": entry["email"], + "registration_date": entry["datetime"], + "age_hours": age.total_seconds() / 3600 + }) + except httpx.RequestError as ex: + logger.error(f"Error checking username {username}: {ex}") + return templates.TemplateResponse("unfulfilled_registrations.html", {"request": request, "registrations": unfulfilled}) @router.post("/purge_unfulfilled_registrations", response_class=JSONResponse) -async def purge_unfulfilled_registrations(min_age_hours: int = Form(default=24)): +async def purge_unfulfilled_registrations(min_age_hours: int = Form(default=24), auth_token: str = Depends(verify_admin_auth)): registrations = load_registrations() if not registrations: return JSONResponse({"message": "No registrations found to clean up"}) @@ -62,8 +119,21 @@ async def purge_unfulfilled_registrations(min_age_hours: int = Form(default=24)) logger.info(f"Cleanup complete: {result}") return JSONResponse(result) +@router.get("/view_undocumented", response_class=HTMLResponse) +async def view_undocumented_users(request: Request, auth_token: str = Depends(verify_admin_auth)): + registrations = load_registrations() + matrix_users = await get_matrix_users() + registered_usernames = {entry["requested_name"].lower() for entry in registrations} + homeserver = config["homeserver"].lower() + undocumented_users = [ + user for user in matrix_users + if user.lower().startswith("@") and user[1:].lower().split(":", 1)[1] == homeserver + and user[1:].lower().split(":", 1)[0] not in registered_usernames + ] + return templates.TemplateResponse("undocumented_users.html", {"request": request, "users": undocumented_users}) + @router.post("/deactivate_undocumented_users", response_class=JSONResponse) -async def deactivate_undocumented_users(): +async def deactivate_undocumented_users(auth_token: str = Depends(verify_admin_auth)): registrations = load_registrations() matrix_users = await get_matrix_users() registered_usernames = {entry["requested_name"].lower() for entry in registrations} @@ -93,7 +163,7 @@ async def deactivate_undocumented_users(): return JSONResponse(result) @router.post("/retroactively_document_users", response_class=JSONResponse) -async def retroactively_document_users(): +async def retroactively_document_users(auth_token: str = Depends(verify_admin_auth)): registrations = load_registrations() matrix_users = await get_matrix_users() registered_usernames = {entry["requested_name"].lower() for entry in registrations} diff --git a/sw1tch/routes/canary.py b/sw1tch/routes/canary.py index 4e7eaff..b3b1f96 100644 --- a/sw1tch/routes/canary.py +++ b/sw1tch/routes/canary.py @@ -13,7 +13,7 @@ from urllib3.util.retry import Retry from sw1tch import BASE_DIR, config, logger, verify_admin_auth from sw1tch.utilities.matrix import AsyncClient -router = APIRouter(prefix="/_admin/warrant_canary", dependencies=[Depends(verify_admin_auth)]) +router = APIRouter(prefix="/_admin/canary") templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) ATTESTATIONS_FILE = os.path.join(BASE_DIR, "config", "attestations.txt") @@ -45,8 +45,8 @@ def get_nist_time(): return data["dateTime"] + " UTC" elif "utc_datetime" in data: return data["utc_datetime"] + " UTC" - except requests.RequestException: - pass + except requests.RequestException as e: + logger.error(f"Failed to fetch NIST time from {url}: {e}") raise HTTPException(status_code=500, detail="Failed to fetch NIST time") def get_rss_headline(): @@ -60,18 +60,19 @@ def get_rss_headline(): def get_bitcoin_latest_block(): try: response = requests.get("https://blockchain.info/latestblock", timeout=10) - if response.status_code == 200: - data = response.json() - block_response = requests.get(f"https://blockchain.info/rawblock/{data['hash']}", timeout=10) - if block_response.status_code == 200: - block_data = block_response.json() - hash_str = data["hash"].lstrip("0") or "0" - return { - "height": data["height"], - "hash": hash_str, - "time": datetime.datetime.fromtimestamp(block_data["time"]).strftime("%Y-%m-%d %H:%M:%S UTC") - } - except Exception: + response.raise_for_status() + data = response.json() + block_response = requests.get(f"https://blockchain.info/rawblock/{data['hash']}", timeout=10) + block_response.raise_for_status() + block_data = block_response.json() + hash_str = data["hash"].lstrip("0") or "0" + return { + "height": data["height"], + "hash": hash_str, + "time": datetime.datetime.fromtimestamp(block_data["time"]).strftime("%Y-%m-%d %H:%M:%S UTC") + } + except requests.RequestException as e: + logger.error(f"Failed to fetch Bitcoin block data: {e}") raise HTTPException(status_code=500, detail="Failed to fetch Bitcoin block data") def create_warrant_canary_message(attestations: List[str], note: str): @@ -99,7 +100,7 @@ def sign_with_gpg(message: str, gpg_key_id: str, passphrase: str): with open(TEMP_CANARY_FILE, "w", newline='\n') as f: f.write(message) cmd = ["gpg", "--batch", "--yes", "--passphrase", passphrase, "--clearsign", "--default-key", gpg_key_id, TEMP_CANARY_FILE] - subprocess.run(cmd, check=True) + subprocess.run(cmd, check=True, capture_output=True, text=True) with open(f"{TEMP_CANARY_FILE}.asc", "r") as f: signed_message = f.read() os.remove(TEMP_CANARY_FILE) @@ -110,7 +111,11 @@ def sign_with_gpg(message: str, gpg_key_id: str, passphrase: str): lines.pop(signature_idx + 1) return "\n".join(lines) except subprocess.CalledProcessError as e: - raise HTTPException(status_code=500, detail=f"GPG signing failed: {e}") + logger.error(f"GPG signing failed: {e.stderr}") + raise HTTPException(status_code=500, detail=f"GPG signing failed: {e.stderr}") + except Exception as e: + logger.error(f"Error during GPG signing: {e}") + raise HTTPException(status_code=500, detail=f"Error during GPG signing: {e}") async def post_to_matrix(signed_message: str): try: @@ -132,36 +137,62 @@ async def post_to_matrix(signed_message: str): f"<pre>{signed_message}</pre>" ) } - await client.room_send(config['matrix_admin']['room'], "m.room.message", content) + await client.room_send(config['canary']['room'], "m.room.message", content) await client.logout() await client.close() + logger.info("Warrant canary posted to Matrix successfully") return True except Exception as e: logger.error(f"Error posting to Matrix: {e}") return False @router.get("/", response_class=HTMLResponse) -async def warrant_canary_form(request: Request): +async def warrant_canary_form(request: Request, auth_token: str = Depends(verify_admin_auth)): attestations = load_attestations() - return templates.TemplateResponse("canary_form.html", { + return templates.TemplateResponse("canary.html", { "request": request, "attestations": attestations, "organization": config["canary"]["organization"] }) @router.post("/preview", response_class=HTMLResponse) -async def warrant_canary_preview(request: Request, attestations: List[str] = Form(...), note: str = Form(default="")): - message = create_warrant_canary_message(attestations, note) - return templates.TemplateResponse("canary_preview.html", {"request": request, "message": message}) +async def warrant_canary_preview( + request: Request, + selected_attestations: List[str] = Form(...), + note: str = Form(default=""), + auth_token: str = Depends(verify_admin_auth) +): + message = create_warrant_canary_message(selected_attestations, note) + return templates.TemplateResponse("canary_preview.html", { + "request": request, + "message": message, + "selected_attestations": selected_attestations, + "note": note + }) @router.post("/sign", response_class=HTMLResponse) -async def warrant_canary_sign(request: Request, message: str = Form(...), passphrase: str = Form(...)): +async def warrant_canary_sign( + request: Request, + message: str = Form(...), + passphrase: str = Form(...), + auth_token: str = Depends(verify_admin_auth) +): signed_message = sign_with_gpg(message, config["canary"]["gpg_key_id"], passphrase) with open(CANARY_OUTPUT_FILE, "w") as f: f.write(signed_message) - return templates.TemplateResponse("canary_success.html", {"request": request, "signed_message": signed_message}) + logger.info(f"Warrant canary saved to {CANARY_OUTPUT_FILE}") + return templates.TemplateResponse("canary_success.html", { + "request": request, + "signed_message": signed_message + }) @router.post("/post", response_class=JSONResponse) -async def warrant_canary_post(signed_message: str = Form(...)): +async def warrant_canary_post( + signed_message: str = Form(...), + auth_token: str = Depends(verify_admin_auth) +): success = await post_to_matrix(signed_message) - return JSONResponse({"message": "Posted to Matrix" if success else "Failed to post to Matrix"}) + return JSONResponse({ + "message": "Posted to Matrix successfully" if success else "Failed to post to Matrix", + "success": success + }) diff --git a/sw1tch/static/styles.css b/sw1tch/static/styles.css index 3f07797..fa6bd74 100644 --- a/sw1tch/static/styles.css +++ b/sw1tch/static/styles.css @@ -33,7 +33,7 @@ body { border-radius: 0.75rem; padding: 2rem; width: 100%; - max-width: 24rem; + max-width: 80%; /* Changed from 24rem to match results-container */ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); } @@ -135,4 +135,82 @@ button:hover, .button:hover { .message p { margin: 0.5rem 0; -} \ No newline at end of file +} + +.list-container { + margin-top: 0; /* Remove top margin since it’s now in results-container */ +} + +.data-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; +} + +.data-table th, +.data-table td { + padding: 0.75rem; + border: 1px solid var(--border-color); + text-align: left; + overflow-wrap: break-word; /* Ensure long text wraps */ +} + +.data-table th { + background-color: var(--input-bg); + font-weight: 500; +} + +.data-table tr:nth-child(even) { + background-color: var(--card-bg); +} + +.data-table tr:hover { + background-color: var(--input-bg); +} + +/* Add to the end of sw1tch/static/styles.css */ + +fieldset { + border: 1px solid var(--border-color); + padding: 1rem; + margin-bottom: 1rem; + border-radius: 0.375rem; +} + +legend { + padding: 0 0.5rem; + color: var(--text-secondary); +} + +.checkbox-label { + display: block; + margin: 0.5rem 0; + color: var(--text-primary); +} + +textarea { + width: 100%; + padding: 0.5rem 1rem; + background-color: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: 0.375rem; + color: var(--text-primary); + font-size: 1rem; + transition: border-color 0.15s ease; + resize: vertical; +} + +textarea:focus { + outline: none; + border-color: var(--accent); +} + +.canary-preview { + background-color: var(--input-bg); + padding: 1rem; + border-radius: 0.375rem; + white-space: pre-wrap; + word-wrap: break-word; + margin: 1rem 0; + font-family: monospace; +} diff --git a/sw1tch/templates/admin.html b/sw1tch/templates/admin.html new file mode 100644 index 0000000..e8fdf72 --- /dev/null +++ b/sw1tch/templates/admin.html @@ -0,0 +1,99 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Admin Panel</title> + <link rel="stylesheet" href="/static/styles.css"> + <style> + .grid-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 2rem; + } + .admin-button { + padding: 1rem; + font-size: 1rem; + } + .login-container { + display: flex; + flex-direction: column; + gap: 1rem; + } + .results-container { + width: 80%; + margin: 0 auto 2rem auto; + max-height: none; /* Allow height to adjust to content */ + } + </style> +</head> +<body> + <div class="card"> + <div class="logo-container"> + <img src="/static/logo.png" alt="Logo" class="logo"> + </div> + + {% if not authenticated %} + <div class="login-container"> + <form id="login-form" method="post" action="/_admin/login"> + <input type="password" name="password" placeholder="Admin Password" required> + <button type="submit">Login</button> + </form> + {% if error %} + <p class="message" style="color: var(--error-color);">{{ error }}</p> + {% endif %} + </div> + {% else %} + <div class="grid-container"> + <button onclick="viewUnfulfilled('{{ request.query_params.auth_token }}')" class="admin-button">View Unfulfilled Registrations</button> + <button onclick="viewUndocumented('{{ request.query_params.auth_token }}')" class="admin-button">View Undocumented Users</button> + </div> + <div id="results" class="results-container"></div> + <div class="grid-container"> + <button onclick="purgeUnfulfilled('{{ request.query_params.auth_token }}')" class="admin-button">Purge Unfulfilled Registrations</button> + <button onclick="deactivateUndocumented('{{ request.query_params.auth_token }}')" class="admin-button">Deactivate Undocumented Users</button> + </div> + <p class="info-text">Logged in as admin</p> + {% endif %} + </div> + + <script> + async function viewUnfulfilled(auth_token) { + const response = await fetch(`/_admin/view_unfulfilled?auth_token=${auth_token}`); + const html = await response.text(); + document.getElementById('results').innerHTML = html; + } + + async function purgeUnfulfilled(auth_token) { + if (confirm("Are you sure you want to purge unfulfilled registrations?")) { + const response = await fetch(`/_admin/purge_unfulfilled_registrations?auth_token=${auth_token}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "min_age_hours=24" + }); + const result = await response.json(); + alert(`Cleanup complete:\nKept existing: ${result.kept_existing}\nKept recent: ${result.kept_recent}\nRemoved: ${result.removed}`); + viewUnfulfilled(auth_token); // Refresh the list + } + } + + async function viewUndocumented(auth_token) { + const response = await fetch(`/_admin/view_undocumented?auth_token=${auth_token}`); + const html = await response.text(); + document.getElementById('results').innerHTML = html; + } + + async function deactivateUndocumented(auth_token) { + if (confirm("Are you sure you want to deactivate undocumented users?")) { + const response = await fetch(`/_admin/deactivate_undocumented_users?auth_token=${auth_token}`, { + method: "POST" + }); + const result = await response.json(); + alert(`${result.message}\nFailed deactivations: ${result.failed_deactivations || 'None'}`); + viewUndocumented(auth_token); // Refresh the list + } + } + </script> +</body> +</html> diff --git a/sw1tch/templates/canary.html b/sw1tch/templates/canary.html new file mode 100644 index 0000000..0151284 --- /dev/null +++ b/sw1tch/templates/canary.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Warrant Canary</title> + <link rel="stylesheet" href="/static/styles.css"> +</head> +<body> + <div class="card"> + <div class="logo-container"> + <img src="/static/logo.png" alt="Logo" class="logo"> + </div> + <h2>Create Warrant Canary</h2> + <form method="post" action="/_admin/canary/preview?auth_token={{ request.query_params.auth_token }}"> + <fieldset> + <legend>Select Attestations for {{ organization }}</legend> + {% for attestation in attestations %} + <label class="checkbox-label"> + <input type="checkbox" name="selected_attestations" value="{{ attestation }}"> + {{ organization }} {{ attestation }} + </label> + {% endfor %} + </fieldset> + <label for="note">Optional Note:</label> + <textarea id="note" name="note" rows="3" placeholder="Add an optional note"></textarea> + <button type="submit">Preview Canary</button> + </form> + <p class="info-text"><a href="/_admin/?auth_token={{ request.query_params.auth_token }}">Back to Admin Panel</a></p> + </div> +</body> +</html> diff --git a/sw1tch/templates/canary_preview.html b/sw1tch/templates/canary_preview.html index 361091a..454f226 100644 --- a/sw1tch/templates/canary_preview.html +++ b/sw1tch/templates/canary_preview.html @@ -1,16 +1,25 @@ <!DOCTYPE html> -<html> +<html lang="en"> <head> - <title>Warrant Canary Preview</title> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Preview Warrant Canary</title> + <link rel="stylesheet" href="/static/styles.css"> </head> <body> - <h1>Warrant Canary Preview</h1> - <pre>{{ message }}</pre> - <form method="post" action="/_admin/warrant_canary/sign"> - <input type="hidden" name="message" value="{{ message|escape }}"> - <input type="password" name="passphrase" placeholder="GPG Passphrase" required> - <input type="hidden" name="auth_token" value="{{ request.query_params.auth_token }}"> - <button type="submit">Sign</button> - </form> + <div class="card"> + <div class="logo-container"> + <img src="/static/logo.png" alt="Logo" class="logo"> + </div> + <h2>Preview Warrant Canary</h2> + <pre class="canary-preview">{{ message }}</pre> + <form method="post" action="/_admin/canary/sign?auth_token={{ request.query_params.auth_token }}"> + <input type="hidden" name="message" value="{{ message | escape }}"> + <label for="passphrase">GPG Passphrase:</label> + <input type="password" id="passphrase" name="passphrase" required> + <button type="submit">Sign and Save</button> + </form> + <p class="info-text"><a href="/_admin/canary/?auth_token={{ request.query_params.auth_token }}">Back to Form</a></p> + </div> </body> </html> diff --git a/sw1tch/templates/canary_success.html b/sw1tch/templates/canary_success.html index 60e614f..fb3e6ca 100644 --- a/sw1tch/templates/canary_success.html +++ b/sw1tch/templates/canary_success.html @@ -1,15 +1,33 @@ <!DOCTYPE html> -<html> +<html lang="en"> <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Warrant Canary Signed</title> + <link rel="stylesheet" href="/static/styles.css"> </head> <body> - <h1>Warrant Canary Signed</h1> - <pre>{{ signed_message }}</pre> - <form method="post" action="/_admin/warrant_canary/post"> - <input type="hidden" name="signed_message" value="{{ signed_message|escape }}"> - <input type="hidden" name="auth_token" value="{{ request.query_params.auth_token }}"> - <button type="submit">Post to Matrix</button> - </form> + <div class="card"> + <div class="logo-container"> + <img src="/static/logo.png" alt="Logo" class="logo"> + </div> + <h2>Warrant Canary Signed</h2> + <pre class="canary-preview">{{ signed_message }}</pre> + <button onclick="postToMatrix('{{ signed_message | escape }}', '{{ request.query_params.auth_token }}')">Post to Matrix</button> + <p id="post-result" class="message"></p> + <p class="info-text"><a href="/_admin/?auth_token={{ request.query_params.auth_token }}">Back to Admin Panel</a></p> + </div> + <script> + async function postToMatrix(signed_message, auth_token) { + const response = await fetch(`/_admin/canary/post?auth_token=${auth_token}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `signed_message=${encodeURIComponent(signed_message)}` + }); + const result = await response.json(); + document.getElementById("post-result").textContent = result.message; + document.getElementById("post-result").style.color = result.success ? "var(--success-color)" : "var(--error-color)"; + } + </script> </body> </html> diff --git a/sw1tch/templates/undocumented_users.html b/sw1tch/templates/undocumented_users.html new file mode 100644 index 0000000..ca598af --- /dev/null +++ b/sw1tch/templates/undocumented_users.html @@ -0,0 +1,21 @@ +<div class="list-container"> + <h2>Undocumented Users</h2> + {% if users %} + <table class="data-table"> + <thead> + <tr> + <th>User ID</th> + </tr> + </thead> + <tbody> + {% for user in users %} + <tr> + <td>{{ user }}</td> + </tr> + {% endfor %} + </tbody> + </table> + {% else %} + <p class="message">No undocumented users found.</p> + {% endif %} +</div> diff --git a/sw1tch/templates/unfulfilled_registrations.html b/sw1tch/templates/unfulfilled_registrations.html new file mode 100644 index 0000000..9bac1f6 --- /dev/null +++ b/sw1tch/templates/unfulfilled_registrations.html @@ -0,0 +1,27 @@ +<div class="list-container"> + <h2>Unfulfilled Registrations</h2> + {% if registrations %} + <table class="data-table"> + <thead> + <tr> + <th>Username</th> + <th>Email</th> + <th>Registration Date</th> + <th>Age (Hours)</th> + </tr> + </thead> + <tbody> + {% for reg in registrations %} + <tr> + <td>{{ reg.username }}</td> + <td>{{ reg.email }}</td> + <td>{{ reg.registration_date }}</td> + <td>{{ reg.age_hours | round(1) }}</td> + </tr> + {% endfor %} + </tbody> + </table> + {% else %} + <p class="message">No unfulfilled registrations found.</p> + {% endif %} +</div>