commits
tags
import re
import json
import uuid
from pathlib import Path
from werkzeug.utils import secure_filename
from werkzeug.security import generate_password_hash, check_password_hash
from flask import Blueprint, jsonify, request, abort, send_file, render_template
import db
import extensions
from auth import login_required
from utils.system import local_ip
bp = Blueprint('livechat', __name__)
_HEX_COLOR = re.compile(r'^#[0-9a-fA-F]{6}$')
CHAT_USERS_FILE = Path(__file__).parent.parent.parent / 'chat_users.json'
CHAT_AVATARS_DIR = Path(__file__).parent.parent.parent / 'static' / 'chat_avatars'
CHAT_IMAGES_DIR = Path(__file__).parent.parent.parent / 'static' / 'chat_images'
CHAT_AVATARS_DIR.mkdir(exist_ok=True)
CHAT_IMAGES_DIR.mkdir(exist_ok=True)
_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.webp']
_CHAT_IMG_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
_CHAT_COLORS = [
'#f87171', '#fb923c', '#fbbf24', '#a3e635',
'#34d399', '#38bdf8', '#818cf8', '#e879f9',
'#f472b6', '#94a3b8',
]
# Shared in-memory token store: token -> {username, emoji, color, avatar_url}
# Imported by events.py so both modules share the same dict.
chat_tokens = {}
def _load_chat_users():
try:
if CHAT_USERS_FILE.exists():
return json.loads(CHAT_USERS_FILE.read_text())
except Exception:
pass
return {}
def _save_chat_users(users):
CHAT_USERS_FILE.write_text(json.dumps(users, indent=2))
def get_avatar_url(username):
safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
for ext in _AVATAR_EXTS:
if (CHAT_AVATARS_DIR / (safe + ext)).exists():
return f'/api/chat/avatar/{username}'
return None
def _restore_chat_tokens():
users = _load_chat_users()
for username, u in users.items():
token = u.get('token')
if token:
chat_tokens[token] = {
'username': username,
'emoji': u.get('emoji', '😀'),
'color': u.get('color', '#94a3b8'),
'avatar_url': get_avatar_url(username),
}
_restore_chat_tokens()
# --- Page routes ---
@bp.route('/chat-display')
def chat_display():
return render_template('chat_display.html', local_ip=local_ip())
@bp.route('/chat-mobile')
def chat_mobile():
return render_template('chat_mobile.html')
@bp.route('/api/chat-qr')
def chat_qr():
try:
import qrcode as _qr
import io as _io
url = f'http://{local_ip()}:5000/chat-mobile'
qr = _qr.QRCode(box_size=20, border=2)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image()
buf = _io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return send_file(buf, mimetype='image/png', max_age=30)
except ImportError:
abort(501)
# --- Auth routes ---
@bp.route('/api/chat/register', methods=['POST'])
def chat_register():
data = request.get_json(silent=True) or {}
username = str(data.get('username', '')).strip()[:32]
password = str(data.get('password', '')).strip()
emoji = str(data.get('emoji', '😀')).strip()[:4]
if not username or not password:
return jsonify({'error': 'Användarnamn och lösenord krävs'}), 400
users = _load_chat_users()
if username.lower() in {k.lower() for k in users}:
return jsonify({'error': 'Användarnamnet är redan taget'}), 409
color = _CHAT_COLORS[len(users) % len(_CHAT_COLORS)]
token = str(uuid.uuid4())
users[username] = {
'password_hash': generate_password_hash(password),
'emoji': emoji,
'color': color,
'token': token,
}
_save_chat_users(users)
chat_tokens[token] = {'username': username, 'emoji': emoji, 'color': color, 'avatar_url': None}
return jsonify({'token': token, 'username': username, 'emoji': emoji, 'color': color, 'avatar_url': None})
@bp.route('/api/chat/login', methods=['POST'])
def chat_login():
data = request.get_json(silent=True) or {}
username = str(data.get('username', '')).strip()
password = str(data.get('password', '')).strip()
if not username or not password:
return jsonify({'error': 'Fyll i användarnamn och lösenord'}), 400
users = _load_chat_users()
key = next((k for k in users if k.lower() == username.lower()), None)
if not key or not check_password_hash(users[key]['password_hash'], password):
return jsonify({'error': 'Fel användarnamn eller lösenord'}), 401
u = users[key]
avatar_url = get_avatar_url(key)
token = str(uuid.uuid4())
chat_tokens[token] = {'username': key, 'emoji': u['emoji'], 'color': u['color'], 'avatar_url': avatar_url}
users[key]['token'] = token
_save_chat_users(users)
return jsonify({'token': token, 'username': key, 'emoji': u['emoji'], 'color': u['color'], 'avatar_url': avatar_url})
@bp.route('/api/chat/validate', methods=['POST'])
def chat_validate():
data = request.get_json(silent=True) or {}
token = str(data.get('token', ''))
user = chat_tokens.get(token)
if not user:
return jsonify({'error': 'Ogiltig token'}), 401
if not user.get('avatar_url'):
user['avatar_url'] = get_avatar_url(user['username'])
return jsonify({'username': user['username'], 'emoji': user['emoji'], 'color': user['color'], 'avatar_url': user.get('avatar_url')})
# --- Avatar & image routes ---
@bp.route('/api/chat/avatar/<username>')
def serve_chat_avatar(username):
safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
for ext in _AVATAR_EXTS:
p = CHAT_AVATARS_DIR / (safe + ext)
if p.exists():
return send_file(p)
abort(404)
@bp.route('/api/chat/avatar', methods=['POST'])
def upload_chat_avatar():
token = request.form.get('token', '')
user_info = chat_tokens.get(token)
if not user_info:
return jsonify({'error': 'Ogiltig token'}), 401
f = request.files.get('avatar')
if not f or not f.filename:
return jsonify({'error': 'Ingen fil'}), 400
ext = Path(secure_filename(f.filename)).suffix.lower()
if ext not in _AVATAR_EXTS:
return jsonify({'error': 'Använd jpg, png eller webp'}), 400
username = user_info['username']
safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
for old_ext in _AVATAR_EXTS:
old = CHAT_AVATARS_DIR / (safe + old_ext)
if old.exists():
old.unlink()
dest = CHAT_AVATARS_DIR / (safe + ext)
f.save(str(dest))
avatar_url = f'/api/chat/avatar/{username}'
user_info['avatar_url'] = avatar_url
users = _load_chat_users()
if username in users:
users[username]['avatar_url'] = avatar_url
_save_chat_users(users)
return jsonify({'avatar_url': avatar_url})
@bp.route('/api/chat/image', methods=['POST'])
def upload_chat_image():
token = request.form.get('token', '')
user_info = chat_tokens.get(token)
if not user_info:
return jsonify({'error': 'Ogiltig token'}), 401
f = request.files.get('image')
if not f or not f.filename:
return jsonify({'error': 'Ingen fil'}), 400
ext = Path(secure_filename(f.filename)).suffix.lower()
if ext not in _CHAT_IMG_EXTS:
return jsonify({'error': 'Använd jpg, png, gif eller webp'}), 400
filename = uuid.uuid4().hex + ext
f.save(str(CHAT_IMAGES_DIR / filename))
return jsonify({'url': f'/static/chat_images/{filename}'})
# --- Chat overlay settings (YouTube chat + kiosk chat display) ---
@bp.route('/api/chat', methods=['GET'])
def get_chat():
state = db.get_state()
return jsonify({
'kiosk_enabled': state.get('kiosk_chat_enabled', '0') == '1',
'kiosk_position': state.get('kiosk_chat_position', 'right'),
'kiosk_width_pct': int(state.get('kiosk_chat_width', '30')),
'kiosk_height': int(state.get('kiosk_chat_height','80')),
})
@bp.route('/api/chat', methods=['POST'])
@login_required
def set_chat():
data = request.get_json()
if not data:
abort(400)
kiosk_enabled = bool(data.get('kiosk_enabled', False))
kiosk_position = 'left' if data.get('kiosk_position') == 'left' else 'right'
kiosk_width_pct = max(10, min(60, int(data.get('kiosk_width_pct', 30))))
kiosk_height = max(30, min(100, int(data.get('kiosk_height', 80))))
db.set_state('kiosk_chat_enabled', '1' if kiosk_enabled else '0')
db.set_state('kiosk_chat_position', kiosk_position)
db.set_state('kiosk_chat_width', str(kiosk_width_pct))
db.set_state('kiosk_chat_height', str(kiosk_height))
payload = {
'kiosk_enabled': kiosk_enabled, 'kiosk_position': kiosk_position,
'kiosk_width_pct': kiosk_width_pct, 'kiosk_height': kiosk_height,
}
extensions.socketio.emit('chat_settings', payload)
return '', 204