commit 57c920b57f0c65127224b58c8f68bbe74b843cf7 Author: Your Name Date: Sun Dec 14 03:05:43 2025 -0600 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c300311 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# data +*.db +*.sqlite +/data/ + +# python +__pycache__/ +*.pyc +.venv/ + +# docker +.docker/ + +# os +.DS_Store +Thumbs.db + +# env +.env +*.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fca81c5 --- /dev/null +++ b/Dockerfile @@ -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/ diff --git a/backend.py b/backend.py new file mode 100644 index 0000000..7d027e0 --- /dev/null +++ b/backend.py @@ -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() diff --git a/browser_proxy.py b/browser_proxy.py new file mode 100644 index 0000000..a09c250 --- /dev/null +++ b/browser_proxy.py @@ -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 = '' + if '' in body_str.lower(): + body_str = re.sub(r'(]*>)', r'\1' + base_tag, body_str, count=1, flags=re.IGNORECASE) + elif '' in body_str.lower(): + body_str = re.sub(r'(]*>)', r'\1' + base_tag + '', 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() diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000..b4da5cd --- /dev/null +++ b/dashboard.html @@ -0,0 +1,4837 @@ + + + + + + dashd + + + + + + +
+
+
sign in
+ + + + + +
need an account? register
+
+
+ +
+ + +
+ + + + +
+
+ +
+
+
+
+
+
no services pinned
+
click + to add or ⚙ to scan
+
+
+
+ + +
+ + +
+
open
+
copy url
+
edit
+
delete
+
+ + + + + + + + + + + + + + + + + +
+ + +
+ + + + diff --git a/dashd_icon.png b/dashd_icon.png new file mode 100644 index 0000000..1d8d305 Binary files /dev/null and b/dashd_icon.png differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..abcc713 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/mail_proxy.py b/mail_proxy.py new file mode 100755 index 0000000..9aaaffd --- /dev/null +++ b/mail_proxy.py @@ -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/ - 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/") + print(f" POST /api/mail/thread/") + print(f" POST /api/mail/send") + server.serve_forever() diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..e628d93 --- /dev/null +++ b/nginx.conf @@ -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; + } + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c993a50 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyJWT>=2.8.0 diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..f18efef --- /dev/null +++ b/start.sh @@ -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;'