jaeswift-website/api/app.py
jae ccbd59fcd4 fix: resolve all critical audit issues
- Fix 60 DOM ID mismatches in admin.js (editor, dashboard, API keys, theme, services, navigation, links)
- Add 51 missing CSS classes to admin.css (sidebar, topbar, login, editor, tables, settings, backups, etc)
- Fix 5 undefined onclick methods in admin.html (saveContact, saveSEO, remove unused save buttons)
- Fix API payload mismatches: services/nav/links send single object, apikeys nested {group, data} format
- Replace Promise.all with Promise.allSettled in loadDashboard for resilient loading
- Fix /api/services timeout: ThreadPoolExecutor parallel checks + timeout=2s
- Add /etc/hosts entries on VPS for subdomain resolution from localhost
- Add JSON error handlers (400, 401, 404, 500) to Flask API
- Suppress InsecureRequestWarning in Flask
- Fix dashThreats container ID mismatch in admin.html
- Delete stale root-level JS files from VPS
2026-04-01 00:54:20 +00:00

698 lines
25 KiB
Python
Raw Blame History

#!/usr/bin/env python3
"""JAESWIFT HUD Backend API"""
import json, os, time, subprocess, random, datetime, hashlib, zipfile, io, smtplib
from functools import wraps
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from flask import Flask, request, jsonify, abort, send_file
from flask_cors import CORS
import jwt
import requests as req
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
app = Flask(__name__)
CORS(app)
DATA_DIR = Path(__file__).parent / 'data'
JWT_SECRET = 'jaeswift-hud-s3cr3t-2026!x'
ADMIN_USER = 'jae'
ADMIN_PASS = 'HUDAdmin2026!'
ARRAY_FILES = {
'posts.json', 'tracks.json', 'navigation.json', 'links.json',
'managed_services.json', 'messages.json'
}
# ─── JSON Error Handlers ─────────────────────────────
@app.errorhandler(400)
def bad_request(e):
return jsonify({'error': 'Bad Request', 'message': str(e.description) if hasattr(e, 'description') else str(e)}), 400
@app.errorhandler(401)
def unauthorized(e):
return jsonify({'error': 'Unauthorized', 'message': str(e.description) if hasattr(e, 'description') else str(e)}), 401
@app.errorhandler(404)
def not_found(e):
return jsonify({'error': 'Not Found', 'message': str(e.description) if hasattr(e, 'description') else str(e)}), 404
@app.errorhandler(500)
def server_error(e):
return jsonify({'error': 'Internal Server Error', 'message': str(e.description) if hasattr(e, 'description') else str(e)}), 500
# ─── Helpers ─────────────────────────────────────────
def load_json(name):
p = DATA_DIR / name
if p.exists():
with open(p) as f:
return json.load(f)
return [] if name in ARRAY_FILES else {}
def save_json(name, data):
p = DATA_DIR / name
p.parent.mkdir(parents=True, exist_ok=True)
with open(p, 'w') as f:
json.dump(data, f, indent=2)
def require_auth(fn):
@wraps(fn)
def wrapper(*a, **kw):
auth = request.headers.get('Authorization', '')
if not auth.startswith('Bearer '):
abort(401, 'Missing token')
try:
jwt.decode(auth[7:], JWT_SECRET, algorithms=['HS256'])
except Exception:
abort(401, 'Invalid token')
return fn(*a, **kw)
return wrapper
def shell(cmd):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
return r.stdout.strip()
except Exception:
return ''
# ─── Auth ────────────────────────────────────────────
@app.route('/api/auth/login', methods=['POST'])
def login():
d = request.get_json(force=True, silent=True) or {}
if d.get('username') == ADMIN_USER and d.get('password') == ADMIN_PASS:
token = jwt.encode(
{'user': ADMIN_USER, 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24)},
JWT_SECRET, algorithm='HS256'
)
return jsonify({'token': token})
return jsonify({'error': 'Invalid credentials'}), 401
@app.route('/api/auth/check')
def auth_check():
auth = request.headers.get('Authorization', '')
if not auth.startswith('Bearer '):
return jsonify({'valid': False}), 401
try:
jwt.decode(auth[7:], JWT_SECRET, algorithms=['HS256'])
return jsonify({'valid': True, 'user': ADMIN_USER})
except Exception:
return jsonify({'valid': False}), 401
# ─── Blog Posts ──────────────────────────────────────
@app.route('/api/posts')
def get_posts():
posts = load_json('posts.json')
return jsonify(posts)
@app.route('/api/posts/<slug>')
def get_post(slug):
posts = load_json('posts.json')
for p in posts:
if p.get('slug') == slug:
return jsonify(p)
abort(404)
@app.route('/api/posts', methods=['POST'])
@require_auth
def create_post():
d = request.get_json(force=True)
posts = load_json('posts.json')
d['id'] = max((p.get('id', 0) for p in posts), default=0) + 1
d['date'] = d.get('date', datetime.date.today().isoformat())
d['word_count'] = len(d.get('content', '').split())
posts.append(d)
save_json('posts.json', posts)
return jsonify(d), 201
@app.route('/api/posts/<slug>', methods=['PUT'])
@require_auth
def update_post(slug):
d = request.get_json(force=True)
posts = load_json('posts.json')
for i, p in enumerate(posts):
if p.get('slug') == slug:
d['id'] = p['id']
d['word_count'] = len(d.get('content', '').split())
posts[i] = {**p, **d}
save_json('posts.json', posts)
return jsonify(posts[i])
abort(404)
@app.route('/api/posts/<slug>', methods=['DELETE'])
@require_auth
def delete_post(slug):
posts = load_json('posts.json')
posts = [p for p in posts if p.get('slug') != slug]
save_json('posts.json', posts)
return jsonify({'ok': True})
# ─── Server Stats ────────────────────────────────────
@app.route('/api/stats')
def server_stats():
# CPU
load = shell("cat /proc/loadavg | awk '{print $1}'")
ncpu = shell("nproc")
try:
cpu_pct = round(float(load) / max(int(ncpu), 1) * 100, 1)
except Exception:
cpu_pct = 0
# Memory
mem = shell("free | awk '/Mem:/{printf \"%.1f\", $3/$2*100}'")
# Disk
disk = shell("df / | awk 'NR==2{print $5}' | tr -d '%'")
# Network (bytes since boot)
net = shell("cat /proc/net/dev | awk '/eth0|ens/{print $2,$10}'")
parts = net.split()
rx = int(parts[0]) if len(parts) >= 2 else 0
tx = int(parts[1]) if len(parts) >= 2 else 0
# Docker
running = shell("docker ps -q 2>/dev/null | wc -l")
total = shell("docker ps -aq 2>/dev/null | wc -l")
# Uptime
up = shell("cat /proc/uptime | awk '{print $1}'")
# Connections
conns = shell("ss -s | awk '/TCP:/{print $2}'")
return jsonify({
'cpu_percent': cpu_pct,
'memory_percent': float(mem) if mem else 0,
'disk_percent': int(disk) if disk else 0,
'network_rx_bytes': rx,
'network_tx_bytes': tx,
'container_running': int(running) if running else 0,
'container_total': int(total) if total else 0,
'uptime_seconds': float(up) if up else 0,
'active_connections': int(conns) if conns else 0,
'load_avg': float(load) if load else 0,
'timestamp': time.time()
})
# ─── Services Status ─────────────────────────────────
@app.route('/api/services')
def services():
svcs = [
{'name': 'Gitea', 'url': 'https://git.jaeswift.xyz'},
{'name': 'Plex', 'url': 'https://plex.jaeswift.xyz'},
{'name': 'Search', 'url': 'https://jaeswift.xyz/search'},
{'name': 'Yoink', 'url': 'https://jaeswift.xyz/yoink/'},
{'name': 'Archive', 'url': 'https://archive.jaeswift.xyz'},
{'name': 'Agent Zero', 'url': 'https://agentzero.jaeswift.xyz'},
{'name': 'Files', 'url': 'https://files.jaeswift.xyz'},
]
def check_service(s):
try:
t0 = time.time()
r = req.get(s['url'], timeout=2, verify=False, allow_redirects=True)
ms = round((time.time() - t0) * 1000)
return {**s, 'status': 'online' if r.status_code < 500 else 'offline', 'response_time_ms': ms}
except Exception:
return {**s, 'status': 'offline', 'response_time_ms': 0}
results = [None] * len(svcs)
with ThreadPoolExecutor(max_workers=7) as executor:
futures = {executor.submit(check_service, s): i for i, s in enumerate(svcs)}
for future in as_completed(futures):
results[futures[future]] = future.result()
return jsonify(results)
# ─── Weather ─────────────────────────────────────────
@app.route('/api/weather')
def weather():
try:
r = req.get('https://wttr.in/Manchester?format=j1', timeout=5,
headers={'User-Agent': 'jaeswift-hud'})
d = r.json()
cur = d['current_condition'][0]
return jsonify({
'temp_c': int(cur['temp_C']),
'feels_like': int(cur['FeelsLikeC']),
'condition': cur['weatherDesc'][0]['value'],
'humidity': int(cur['humidity']),
'wind_kph': int(cur['windspeedKmph']),
'wind_dir': cur['winddir16Point'],
'icon': cur.get('weatherCode', ''),
})
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── Now Playing (random track) ──────────────────────
@app.route('/api/nowplaying')
def now_playing():
tracks = load_json('tracks.json')
if not tracks:
return jsonify({'artist': 'Unknown', 'track': 'Silence', 'album': ''})
t = random.choice(tracks)
return jsonify(t)
# ─── Tracks CRUD ─────────────────────────────────────
@app.route('/api/tracks')
def get_tracks():
return jsonify(load_json('tracks.json'))
@app.route('/api/tracks', methods=['POST'])
@require_auth
def add_track():
d = request.get_json(force=True)
tracks = load_json('tracks.json')
tracks.append({
'artist': d.get('artist', ''),
'track': d.get('track', ''),
'album': d.get('album', ''),
})
save_json('tracks.json', tracks)
return jsonify(tracks[-1]), 201
@app.route('/api/tracks/<int:index>', methods=['DELETE'])
@require_auth
def delete_track(index):
tracks = load_json('tracks.json')
if 0 <= index < len(tracks):
removed = tracks.pop(index)
save_json('tracks.json', tracks)
return jsonify({'ok': True, 'removed': removed})
abort(404)
# ─── Git Activity (from Gitea API) ───────────────────
@app.route('/api/git-activity')
def git_activity():
try:
r = req.get('https://git.jaeswift.xyz/api/v1/users/jae/heatmap',
timeout=2, verify=False)
heatmap = r.json() if r.status_code == 200 else []
r2 = req.get('https://git.jaeswift.xyz/api/v1/repos/search?sort=updated&limit=5&owner=jae',
timeout=2, verify=False)
repos = []
if r2.status_code == 200:
data = r2.json().get('data', r2.json()) if isinstance(r2.json(), dict) else r2.json()
for repo in (data if isinstance(data, list) else [])[:5]:
repos.append({
'name': repo.get('name', ''),
'updated': repo.get('updated_at', ''),
'stars': repo.get('stars_count', 0),
'language': repo.get('language', ''),
})
return jsonify({'heatmap': heatmap, 'repos': repos})
except Exception as e:
return jsonify({'heatmap': [], 'repos': [], 'error': str(e)})
# ─── Threat Feed (CVE feed) ──────────────────────────
@app.route('/api/threats')
def threats():
try:
r = req.get('https://cve.circl.lu/api/last/8', timeout=8)
cves = []
if r.status_code == 200:
for item in r.json()[:8]:
cves.append({
'id': item.get('id', ''),
'summary': (item.get('summary', '') or '')[:120],
'published': item.get('Published', ''),
'cvss': item.get('cvss', 0),
})
return jsonify(cves)
except Exception:
return jsonify([])
# ─── Settings ────────────────────────────────────────
@app.route('/api/settings')
def get_settings():
return jsonify(load_json('settings.json'))
@app.route('/api/settings', methods=['PUT'])
@require_auth
def update_settings():
d = request.get_json(force=True)
save_json('settings.json', d)
return jsonify(d)
# ═════════════════════════════════════════════════════
# NEW ENDPOINTS
# ═════════════════════════════════════════════════════
# ─── Homepage Config ───────────────────<E29480><E29480>─────────────
@app.route('/api/homepage')
def get_homepage():
return jsonify(load_json('homepage.json'))
@app.route('/api/homepage', methods=['POST'])
@require_auth
def save_homepage():
try:
d = request.get_json(force=True)
save_json('homepage.json', d)
return jsonify(d)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── Managed Services ───────────────────────────────
@app.route('/api/services/managed')
def get_managed_services():
return jsonify(load_json('managed_services.json'))
@app.route('/api/services/managed', methods=['POST'])
@require_auth
def add_managed_service():
try:
d = request.get_json(force=True)
svcs = load_json('managed_services.json')
if not isinstance(svcs, list):
svcs = []
svcs.append({
'name': d.get('name', ''),
'url': d.get('url', '')
})
save_json('managed_services.json', svcs)
return jsonify(svcs[-1]), 201
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/services/managed/<int:index>', methods=['DELETE'])
@require_auth
def delete_managed_service(index):
svcs = load_json('managed_services.json')
if not isinstance(svcs, list):
svcs = []
if 0 <= index < len(svcs):
removed = svcs.pop(index)
save_json('managed_services.json', svcs)
return jsonify({'ok': True, 'removed': removed})
abort(404)
# ─── Navigation ──────────────────────────────────────
@app.route('/api/navigation')
def get_navigation():
return jsonify(load_json('navigation.json'))
@app.route('/api/navigation', methods=['POST'])
@require_auth
def add_navigation():
try:
d = request.get_json(force=True)
nav = load_json('navigation.json')
if not isinstance(nav, list):
nav = []
nav.append({
'label': d.get('label', ''),
'url': d.get('url', ''),
'order': d.get('order', len(nav) + 1)
})
save_json('navigation.json', nav)
return jsonify(nav[-1]), 201
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/navigation/<int:index>', methods=['DELETE'])
@require_auth
def delete_navigation(index):
nav = load_json('navigation.json')
if not isinstance(nav, list):
nav = []
if 0 <= index < len(nav):
removed = nav.pop(index)
save_json('navigation.json', nav)
return jsonify({'ok': True, 'removed': removed})
abort(404)
# ─── Links ───────────────────────────────────────────
@app.route('/api/links')
def get_links():
return jsonify(load_json('links.json'))
@app.route('/api/links', methods=['POST'])
@require_auth
def add_link():
try:
d = request.get_json(force=True)
links = load_json('links.json')
if not isinstance(links, list):
links = []
links.append({
'name': d.get('name', ''),
'url': d.get('url', ''),
'icon': d.get('icon', ''),
'category': d.get('category', '')
})
save_json('links.json', links)
return jsonify(links[-1]), 201
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/links/<int:index>', methods=['DELETE'])
@require_auth
def delete_link(index):
links = load_json('links.json')
if not isinstance(links, list):
links = []
if 0 <= index < len(links):
removed = links.pop(index)
save_json('links.json', links)
return jsonify({'ok': True, 'removed': removed})
abort(404)
# ─── API Keys ────────────────────────────────────────
def mask_value(val):
"""Mask a string value, showing only last 4 chars if longer than 4."""
if not isinstance(val, str) or len(val) == 0:
return val
if len(val) <= 4:
return '••••'
return '••••••' + val[-4:]
def is_masked(val):
"""Check if a value is a masked placeholder."""
if not isinstance(val, str):
return False
return '••••' in val
@app.route('/api/apikeys')
@require_auth
def get_apikeys():
try:
keys = load_json('apikeys.json')
masked = {}
for group, fields in keys.items():
if isinstance(fields, dict):
masked[group] = {}
for k, v in fields.items():
masked[group][k] = mask_value(v)
else:
masked[group] = mask_value(fields)
return jsonify(masked)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apikeys', methods=['POST'])
@require_auth
def save_apikeys():
try:
d = request.get_json(force=True)
group = d.get('group', '')
data = d.get('data', {})
if not group or not isinstance(data, dict):
return jsonify({'error': 'Invalid request: need group and data'}), 400
keys = load_json('apikeys.json')
if group not in keys:
keys[group] = {}
# Only update values that are not masked and not empty
for k, v in data.items():
if isinstance(v, str) and (is_masked(v) or v == ''):
continue # Skip masked or empty values
keys[group][k] = v
save_json('apikeys.json', keys)
return jsonify({'ok': True, 'group': group})
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── Theme ───────────────────────────────────────────
@app.route('/api/theme')
def get_theme():
return jsonify(load_json('theme.json'))
@app.route('/api/theme', methods=['POST'])
@require_auth
def save_theme():
try:
d = request.get_json(force=True)
save_json('theme.json', d)
return jsonify(d)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── SEO ─────────────────────────────────────────────
@app.route('/api/seo')
def get_seo():
return jsonify(load_json('seo.json'))
@app.route('/api/seo', methods=['POST'])
@require_auth
def save_seo():
try:
d = request.get_json(force=True)
save_json('seo.json', d)
return jsonify(d)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── Contact Settings ───────────────────────────────
@app.route('/api/contact-settings')
@require_auth
def get_contact_settings():
return jsonify(load_json('contact_settings.json'))
@app.route('/api/contact-settings', methods=['POST'])
@require_auth
def save_contact_settings():
try:
d = request.get_json(force=True)
save_json('contact_settings.json', d)
return jsonify(d)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── Contact Form (public) ──────────────────────────
@app.route('/api/contact', methods=['POST'])
def contact_form():
try:
d = request.get_json(force=True)
name = d.get('name', '').strip()
email = d.get('email', '').strip()
message = d.get('message', '').strip()
if not name or not email or not message:
return jsonify({'error': 'All fields are required'}), 400
# Check if form is enabled
settings = load_json('contact_settings.json')
if not settings.get('form_enabled', True):
return jsonify({'error': 'Contact form is currently disabled'}), 403
contact_email = settings.get('email', '')
auto_reply = settings.get('auto_reply', '')
# Save message to messages.json regardless
messages = load_json('messages.json')
if not isinstance(messages, list):
messages = []
messages.append({
'name': name,
'email': email,
'message': message,
'timestamp': datetime.datetime.utcnow().isoformat() + 'Z'
})
save_json('messages.json', messages)
# Try to send email via SMTP if configured
keys = load_json('apikeys.json')
smtp_cfg = keys.get('smtp', {})
smtp_host = smtp_cfg.get('host', '')
smtp_port = smtp_cfg.get('port', '587')
smtp_user = smtp_cfg.get('user', '')
smtp_pass = smtp_cfg.get('pass', '')
email_sent = False
if smtp_host and smtp_user and smtp_pass and contact_email:
try:
# Send notification to site owner
msg = MIMEMultipart()
msg['From'] = smtp_user
msg['To'] = contact_email
msg['Subject'] = f'[JAESWIFT] Contact from {name}'
body = f"Name: {name}\nEmail: {email}\n\nMessage:\n{message}"
msg.attach(MIMEText(body, 'plain'))
server = smtplib.SMTP(smtp_host, int(smtp_port))
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
# Send auto-reply if configured
if auto_reply:
reply = MIMEText(auto_reply, 'plain')
reply['From'] = smtp_user
reply['To'] = email
reply['Subject'] = 'Re: Your message to JAESWIFT'
server.send_message(reply)
server.quit()
email_sent = True
except Exception:
pass # Email failed, but message is saved
return jsonify({
'ok': True,
'email_sent': email_sent,
'message': 'Message received. Thanks for reaching out!'
})
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── Backups ─────────────────────────────────────────
@app.route('/api/backups/posts')
@require_auth
def backup_posts():
try:
p = DATA_DIR / 'posts.json'
if not p.exists():
return jsonify({'error': 'No posts data found'}), 404
return send_file(p, as_attachment=True, download_name='posts.json', mimetype='application/json')
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/backups/tracks')
@require_auth
def backup_tracks():
try:
p = DATA_DIR / 'tracks.json'
if not p.exists():
return jsonify({'error': 'No tracks data found'}), 404
return send_file(p, as_attachment=True, download_name='tracks.json', mimetype='application/json')
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/backups/settings')
@require_auth
def backup_settings():
try:
p = DATA_DIR / 'settings.json'
if not p.exists():
return jsonify({'error': 'No settings data found'}), 404
return send_file(p, as_attachment=True, download_name='settings.json', mimetype='application/json')
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/backups/all')
@require_auth
def backup_all():
try:
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in DATA_DIR.glob('*.json'):
zf.write(f, f.name)
buf.seek(0)
ts = datetime.datetime.utcnow().strftime('%Y%m%d_%H%M%S')
return send_file(
buf,
as_attachment=True,
download_name=f'jaeswift_backup_{ts}.zip',
mimetype='application/zip'
)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── Run ─────────────────────────────────────────────
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)