initial release - dashd cyberpunk infrastructure dashboard

features:
- grid-locked card positioning with drag/resize
- youtube widgets (click-to-play)
- user authentication with server-side storage
- per-user localStorage caching
- docker deployment ready

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2025-12-14 03:05:43 -06:00
commit 57c920b57f
11 changed files with 5619 additions and 0 deletions

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# data
*.db
*.sqlite
/data/
# python
__pycache__/
*.pyc
.venv/
# docker
.docker/
# os
.DS_Store
Thumbs.db
# env
.env
*.env

31
Dockerfile Normal file
View file

@ -0,0 +1,31 @@
FROM python:3.11-slim
ENV DEBIAN_FRONTEND=noninteractive
WORKDIR /app
# install nginx first
RUN apt-get update && apt-get install -y --no-install-recommends nginx && rm -rf /var/lib/apt/lists/*
# install python deps
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# copy app files
COPY backend.py .
COPY mail_proxy.py .
COPY browser_proxy.py .
COPY nginx.conf /etc/nginx/nginx.conf
COPY dashboard.html ./static/
# create data dir
RUN mkdir -p /data
# start script
COPY start.sh .
RUN chmod +x start.sh
EXPOSE 8085
CMD ["./start.sh"]
COPY dashd_icon.png ./static/

232
backend.py Normal file
View file

@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""
dashd backend - user auth + settings persistence
"""
import os
import json
import sqlite3
import hashlib
import secrets
from datetime import datetime, timedelta
from functools import wraps
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import jwt
DB_PATH = os.environ.get('DASHD_DB', '/data/dashd.db')
JWT_SECRET = os.environ.get('DASHD_SECRET', secrets.token_hex(32))
JWT_EXPIRY_DAYS = 30
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = get_db()
conn.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
''')
conn.execute('''
CREATE TABLE IF NOT EXISTS settings (
user_id INTEGER PRIMARY KEY,
services TEXT DEFAULT '[]',
card_positions TEXT DEFAULT '{}',
card_sizes TEXT DEFAULT '{}',
grid_size TEXT DEFAULT '{}',
machines TEXT DEFAULT '[]',
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
conn.commit()
conn.close()
def hash_password(password):
salt = secrets.token_hex(16)
hashed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
return salt + ':' + hashed.hex()
def verify_password(password, stored):
salt, hashed = stored.split(':')
check = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
return check.hex() == hashed
def create_token(user_id, username):
payload = {
'user_id': user_id,
'username': username,
'exp': datetime.utcnow() + timedelta(days=JWT_EXPIRY_DAYS)
}
return jwt.encode(payload, JWT_SECRET, algorithm='HS256')
def verify_token(token):
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
return payload
except:
return None
class APIHandler(BaseHTTPRequestHandler):
def send_json(self, data, status=200):
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.end_headers()
def get_user(self):
auth = self.headers.get('Authorization', '')
if auth.startswith('Bearer '):
token = auth[7:]
return verify_token(token)
return None
def read_body(self):
length = int(self.headers.get('Content-Length', 0))
if length:
return json.loads(self.rfile.read(length).decode())
return {}
def do_POST(self):
path = urlparse(self.path).path
if path == '/api/auth/register':
data = self.read_body()
username = data.get('username', '').strip().lower()
password = data.get('password', '')
if not username or not password:
return self.send_json({'error': 'username and password required'}, 400)
if len(username) < 3:
return self.send_json({'error': 'username too short'}, 400)
if len(password) < 6:
return self.send_json({'error': 'password too short'}, 400)
conn = get_db()
try:
cur = conn.execute(
'INSERT INTO users (username, password_hash) VALUES (?, ?)',
(username, hash_password(password))
)
user_id = cur.lastrowid
conn.execute('INSERT INTO settings (user_id) VALUES (?)', (user_id,))
conn.commit()
token = create_token(user_id, username)
self.send_json({'token': token, 'username': username})
except sqlite3.IntegrityError:
self.send_json({'error': 'username taken'}, 400)
finally:
conn.close()
elif path == '/api/auth/login':
data = self.read_body()
username = data.get('username', '').strip().lower()
password = data.get('password', '')
conn = get_db()
user = conn.execute(
'SELECT id, password_hash FROM users WHERE username = ?', (username,)
).fetchone()
conn.close()
if user and verify_password(password, user['password_hash']):
token = create_token(user['id'], username)
self.send_json({'token': token, 'username': username})
else:
self.send_json({'error': 'invalid credentials'}, 401)
elif path == '/api/settings/save':
user = self.get_user()
if not user:
return self.send_json({'error': 'unauthorized'}, 401)
data = self.read_body()
conn = get_db()
conn.execute('''
UPDATE settings SET
services = ?,
card_positions = ?,
card_sizes = ?,
grid_size = ?,
machines = ?,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = ?
''', (
json.dumps(data.get('services', [])),
json.dumps(data.get('cardPositions', {})),
json.dumps(data.get('cardSizes', {})),
json.dumps(data.get('gridSize', {})),
json.dumps(data.get('machines', [])),
user['user_id']
))
conn.commit()
conn.close()
self.send_json({'success': True})
else:
self.send_json({'error': 'not found'}, 404)
def do_GET(self):
path = urlparse(self.path).path
if path == '/api/settings/load':
user = self.get_user()
if not user:
return self.send_json({'error': 'unauthorized'}, 401)
conn = get_db()
settings = conn.execute(
'SELECT * FROM settings WHERE user_id = ?', (user['user_id'],)
).fetchone()
conn.close()
if settings:
self.send_json({
'services': json.loads(settings['services']),
'cardPositions': json.loads(settings['card_positions']),
'cardSizes': json.loads(settings['card_sizes']),
'gridSize': json.loads(settings['grid_size']),
'machines': json.loads(settings['machines'])
})
else:
self.send_json({})
elif path == '/api/auth/verify':
user = self.get_user()
if user:
self.send_json({'valid': True, 'username': user['username']})
else:
self.send_json({'valid': False}, 401)
elif path == '/health':
self.send_json({'status': 'ok'})
else:
self.send_json({'error': 'not found'}, 404)
def log_message(self, format, *args):
pass # silent logging
if __name__ == '__main__':
init_db()
port = int(os.environ.get('PORT', 8086))
server = HTTPServer(('0.0.0.0', port), APIHandler)
print(f'dashd backend running on port {port}')
server.serve_forever()

71
browser_proxy.py Normal file
View file

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
browser proxy for dashd - strips X-Frame-Options to allow embedding any page
runs on port 8088
usage: /proxy?url=https://example.com
"""
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import urllib.request
import urllib.error
import ssl
import re
class ProxyHandler(BaseHTTPRequestHandler):
def do_GET(self):
if not self.path.startswith('/proxy'):
self.send_error(404, 'use /proxy?url=...')
return
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
if 'url' not in params:
self.send_error(400, 'missing url parameter')
return
target_url = params['url'][0]
try:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(target_url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
with urllib.request.urlopen(req, context=ctx, timeout=10) as response:
content_type = response.headers.get('Content-Type', 'text/html')
body = response.read()
if 'text/html' in content_type:
body_str = body.decode('utf-8', errors='replace')
base_tag = '<base href="' + target_url + '">'
if '<head>' in body_str.lower():
body_str = re.sub(r'(<head[^>]*>)', r'\1' + base_tag, body_str, count=1, flags=re.IGNORECASE)
elif '<html>' in body_str.lower():
body_str = re.sub(r'(<html[^>]*>)', r'\1<head>' + base_tag + '</head>', body_str, count=1, flags=re.IGNORECASE)
body = body_str.encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Content-Length', len(body))
self.end_headers()
self.wfile.write(body)
except urllib.error.HTTPError as e:
self.send_error(e.code, str(e.reason))
except Exception as e:
self.send_error(500, str(e))
def log_message(self, format, *args):
pass
if __name__ == '__main__':
port = 8088
server = HTTPServer(('0.0.0.0', port), ProxyHandler)
print(f'browser proxy running on port {port}')
server.serve_forever()

4837
dashboard.html Normal file

File diff suppressed because it is too large Load diff

BIN
dashd_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

13
docker-compose.yml Normal file
View file

@ -0,0 +1,13 @@
services:
dashd:
build: .
ports:
- "8085:8085"
volumes:
- dashd_data:/data
environment:
- DASHD_SECRET=${DASHD_SECRET:-}
restart: unless-stopped
volumes:
dashd_data:

354
mail_proxy.py Executable file
View file

@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""
mail proxy for dashd - fetches emails via IMAP, sends via SMTP
endpoints:
POST /api/mail/inbox - list emails
POST /api/mail/read/<id> - get full email + thread
POST /api/mail/send - send reply
"""
import imaplib
import smtplib
import email
from email.header import decode_header
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import ssl
import re
from datetime import datetime
class MailProxyHandler(BaseHTTPRequestHandler):
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
def do_POST(self):
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
data = json.loads(body)
server = data.get('server', '')
user = data.get('user', '')
password = data.get('pass', '')
if self.path.startswith('/api/mail/inbox'):
limit = 10
if 'limit=' in self.path:
try:
limit = int(self.path.split('limit=')[-1].split('&')[0])
except:
pass
messages = fetch_emails(server, user, password, limit)
self.send_json(messages)
elif self.path.startswith('/api/mail/read/'):
msg_id = self.path.split('/api/mail/read/')[-1].split('?')[0]
email_data = fetch_full_email(server, user, password, msg_id)
self.send_json(email_data)
elif self.path.startswith('/api/mail/thread/'):
msg_id = self.path.split('/api/mail/thread/')[-1].split('?')[0]
thread = fetch_thread(server, user, password, msg_id)
self.send_json(thread)
elif self.path == '/api/mail/send':
to = data.get('to', '')
subject = data.get('subject', '')
body_text = data.get('body', '')
in_reply_to = data.get('in_reply_to', '')
references = data.get('references', '')
result = send_email(server, user, password, to, subject, body_text, in_reply_to, references)
self.send_json(result)
else:
self.send_error(404, 'Not found')
except Exception as e:
self.send_json({'error': str(e)}, 500)
def send_json(self, data, status=200):
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def send_error(self, code, message):
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps({'error': message}).encode())
def log_message(self, format, *args):
print(f"[{datetime.now().strftime('%H:%M:%S')}] {args[0]}")
def decode_mime_header(header):
if not header:
return ''
decoded = decode_header(header)
result = []
for part, charset in decoded:
if isinstance(part, bytes):
try:
result.append(part.decode(charset or 'utf-8', errors='replace'))
except:
result.append(part.decode('utf-8', errors='replace'))
else:
result.append(part)
return ''.join(result)
def get_email_body(msg):
"""extract plain text body from email message"""
body = ''
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
content_disposition = str(part.get('Content-Disposition', ''))
if content_type == 'text/plain' and 'attachment' not in content_disposition:
try:
charset = part.get_content_charset() or 'utf-8'
body = part.get_payload(decode=True).decode(charset, errors='replace')
break
except:
pass
elif content_type == 'text/html' and not body and 'attachment' not in content_disposition:
try:
charset = part.get_content_charset() or 'utf-8'
html = part.get_payload(decode=True).decode(charset, errors='replace')
# strip html tags for plain display
body = re.sub('<[^<]+?>', '', html)
body = re.sub(r'\s+', ' ', body).strip()
except:
pass
else:
try:
charset = msg.get_content_charset() or 'utf-8'
body = msg.get_payload(decode=True).decode(charset, errors='replace')
except:
body = str(msg.get_payload())
return body.strip()
def fetch_emails(server, user, password, limit=10):
messages = []
try:
context = ssl.create_default_context()
imap = imaplib.IMAP4_SSL(server, 993, ssl_context=context)
imap.login(user, password)
imap.select('INBOX')
status, data = imap.search(None, 'ALL')
if status != 'OK':
return messages
message_ids = data[0].split()
recent_ids = message_ids[-limit:] if len(message_ids) > limit else message_ids
recent_ids = list(reversed(recent_ids))
for msg_id in recent_ids:
status, msg_data = imap.fetch(msg_id, '(FLAGS RFC822.HEADER)')
if status != 'OK':
continue
flags_data = msg_data[0][0].decode() if msg_data[0][0] else ''
is_read = '\\Seen' in flags_data
header_data = msg_data[0][1]
msg = email.message_from_bytes(header_data)
from_addr = decode_mime_header(msg.get('From', ''))
if '<' in from_addr:
from_addr = from_addr.split('<')[0].strip().strip('"')
subject = decode_mime_header(msg.get('Subject', '(no subject)'))
date_str = msg.get('Date', '')
try:
from email.utils import parsedate_to_datetime
dt = parsedate_to_datetime(date_str)
date_display = dt.strftime('%b %d')
except:
date_display = date_str[:10] if date_str else ''
messages.append({
'id': msg_id.decode(),
'from': from_addr[:30] + '...' if len(from_addr) > 30 else from_addr,
'subject': subject[:50] + '...' if len(subject) > 50 else subject,
'date': date_display,
'read': is_read
})
imap.logout()
except Exception as e:
print(f"IMAP error: {e}")
raise
return messages
def fetch_full_email(server, user, password, msg_id):
"""fetch complete email with body"""
try:
context = ssl.create_default_context()
imap = imaplib.IMAP4_SSL(server, 993, ssl_context=context)
imap.login(user, password)
imap.select('INBOX')
status, msg_data = imap.fetch(msg_id.encode(), '(RFC822)')
if status != 'OK':
return {'error': 'message not found'}
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
from_addr = decode_mime_header(msg.get('From', ''))
from_email = ''
if '<' in from_addr:
match = re.search(r'<([^>]+)>', from_addr)
if match:
from_email = match.group(1)
from_name = from_addr.split('<')[0].strip().strip('"')
else:
from_email = from_addr
from_name = from_addr
to_addr = decode_mime_header(msg.get('To', ''))
subject = decode_mime_header(msg.get('Subject', ''))
date_str = msg.get('Date', '')
message_id = msg.get('Message-ID', '')
references = msg.get('References', '')
in_reply_to = msg.get('In-Reply-To', '')
try:
from email.utils import parsedate_to_datetime
dt = parsedate_to_datetime(date_str)
date_display = dt.strftime('%b %d, %Y at %I:%M %p')
except:
date_display = date_str
body = get_email_body(msg)
# mark as read
imap.store(msg_id.encode(), '+FLAGS', '\\Seen')
imap.logout()
return {
'id': msg_id,
'from_name': from_name,
'from_email': from_email,
'to': to_addr,
'subject': subject,
'date': date_display,
'body': body,
'message_id': message_id,
'references': references,
'in_reply_to': in_reply_to
}
except Exception as e:
print(f"fetch error: {e}")
return {'error': str(e)}
def fetch_thread(server, user, password, msg_id):
"""fetch email thread based on references/subject"""
try:
# first get the target message to find thread references
target = fetch_full_email(server, user, password, msg_id)
if 'error' in target:
return [target]
context = ssl.create_default_context()
imap = imaplib.IMAP4_SSL(server, 993, ssl_context=context)
imap.login(user, password)
imap.select('INBOX')
thread = []
subject_base = re.sub(r'^(Re:\s*|Fwd:\s*)+', '', target['subject'], flags=re.IGNORECASE).strip()
# search by subject
search_subject = subject_base.replace('"', '\\"')[:50]
status, data = imap.search(None, f'SUBJECT "{search_subject}"')
if status == 'OK' and data[0]:
thread_ids = data[0].split()[-10:] # limit to 10 messages
for tid in thread_ids:
status, msg_data = imap.fetch(tid, '(RFC822.HEADER)')
if status != 'OK':
continue
header_data = msg_data[0][1]
msg = email.message_from_bytes(header_data)
from_addr = decode_mime_header(msg.get('From', ''))
if '<' in from_addr:
from_addr = from_addr.split('<')[0].strip().strip('"')
date_str = msg.get('Date', '')
try:
from email.utils import parsedate_to_datetime
dt = parsedate_to_datetime(date_str)
date_display = dt.strftime('%b %d %I:%M %p')
timestamp = dt.timestamp()
except:
date_display = date_str[:16]
timestamp = 0
thread.append({
'id': tid.decode(),
'from': from_addr,
'date': date_display,
'timestamp': timestamp,
'current': tid.decode() == msg_id
})
imap.logout()
# sort by timestamp
thread.sort(key=lambda x: x.get('timestamp', 0))
return thread
except Exception as e:
print(f"thread error: {e}")
return [{'error': str(e)}]
def send_email(server, user, password, to, subject, body, in_reply_to='', references=''):
"""send email via SMTP"""
try:
msg = MIMEMultipart()
msg['From'] = user
msg['To'] = to
msg['Subject'] = subject
if in_reply_to:
msg['In-Reply-To'] = in_reply_to
if references:
msg['References'] = references + ' ' + in_reply_to if in_reply_to else references
msg.attach(MIMEText(body, 'plain'))
context = ssl.create_default_context()
with smtplib.SMTP_SSL(server, 465, context=context) as smtp:
smtp.login(user, password)
smtp.send_message(msg)
return {'success': True, 'message': 'email sent'}
except Exception as e:
print(f"SMTP error: {e}")
return {'error': str(e)}
if __name__ == '__main__':
port = 8087
server = HTTPServer(('0.0.0.0', port), MailProxyHandler)
print(f"mail proxy running on port {port}")
print("endpoints:")
print(f" POST /api/mail/inbox?limit=N")
print(f" POST /api/mail/read/<id>")
print(f" POST /api/mail/thread/<id>")
print(f" POST /api/mail/send")
server.serve_forever()

53
nginx.conf Normal file
View file

@ -0,0 +1,53 @@
worker_processes 1;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 8085;
server_name localhost;
root /app/static;
location = / {
return 301 /dashboard.html;
}
location / {
try_files $uri $uri/ =404;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
location /api/auth {
proxy_pass http://127.0.0.1:8086;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /api/settings {
proxy_pass http://127.0.0.1:8086;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
}
location /api/mail {
proxy_pass http://127.0.0.1:8087;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /proxy {
proxy_pass http://127.0.0.1:8088;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /health {
proxy_pass http://127.0.0.1:8086;
}
}
}

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
PyJWT>=2.8.0

7
start.sh Normal file
View file

@ -0,0 +1,7 @@
#!/bin/bash
# start all services
python3 /app/backend.py &
python3 /app/mail_proxy.py &
python3 /app/browser_proxy.py &
nginx -g 'daemon off;'