shit dont work yet. fix chatting
Signed-off-by: rattatwinko <seppmutterman@gmail.com>
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
Flask>=3.1.2
|
||||
Flask-SQLAlchemy>=3.0.5
|
||||
Flask-Cache>=0.13.1
|
||||
cryptography>=45.0.6
|
||||
Flask==3.0.0
|
||||
Flask-SocketIO==5.3.6
|
||||
Flask-Limiter==3.5.0
|
||||
redis==5.0.1
|
||||
cryptography==41.0.7
|
||||
bcrypt==4.1.2
|
||||
python-dotenv==1.0.0
|
||||
gunicorn==21.2.0
|
||||
557
src/app.py
557
src/app.py
@@ -1,11 +1,554 @@
|
||||
from flask import Flask
|
||||
from todo import todo
|
||||
import eventlet
|
||||
eventlet.monkey_patch()
|
||||
from flask import Flask, make_response, render_template, request, jsonify
|
||||
from flask_socketio import SocketIO, emit, join_room, leave_room, disconnect
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
import collections
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import json
|
||||
import secrets
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from functools import wraps
|
||||
import os
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
socketio = SocketIO(app)
|
||||
|
||||
# Security constants
|
||||
MAX_MESSAGES = 256
|
||||
MAX_ROOM_ID_LENGTH = 32
|
||||
MAX_MESSAGE_SIZE = 8192 # 8KB max encrypted message
|
||||
MAX_ROOMS_PER_IP = 5
|
||||
MAX_USERS_PER_ROOM = 50
|
||||
ROOM_CLEANUP_INTERVAL = 3600 # 1 hour
|
||||
USER_SESSION_TIMEOUT = 3600 # 1 hour
|
||||
RATE_LIMIT_MESSAGE = "10 per minute"
|
||||
RATE_LIMIT_ROOM_JOIN = "5 per minute"
|
||||
|
||||
# In-memory storage with enhanced security
|
||||
chat_rooms = {}
|
||||
room_keys = {}
|
||||
user_sessions = {}
|
||||
room_passwords = {} # Separate password storage with hashing
|
||||
room_creation_times = {}
|
||||
ip_room_count = {} # Track rooms created per IP
|
||||
failed_password_attempts = {} # Track failed password attempts
|
||||
message_hashes = {} # Store message hashes for duplicate detection
|
||||
room_session_keys = {} # Store session keys for each room
|
||||
|
||||
class CircularMessageBuffer:
|
||||
def __init__(self, max_size=MAX_MESSAGES):
|
||||
self.buffer = collections.deque(maxlen=max_size)
|
||||
self.lock = threading.Lock()
|
||||
self.creation_time = time.time()
|
||||
self.last_activity = time.time()
|
||||
|
||||
def add_message(self, message_data):
|
||||
with self.lock:
|
||||
# Generate unique message ID and validate
|
||||
message_id = str(uuid.uuid4())
|
||||
|
||||
# Create message hash for duplicate detection
|
||||
message_hash = hashlib.sha256(
|
||||
f"{message_data['encrypted_content']}{message_data['sender_id']}{message_data.get('iv', '')}".encode()
|
||||
).hexdigest()
|
||||
|
||||
self.buffer.append({
|
||||
'id': message_id,
|
||||
'encrypted_content': message_data['encrypted_content'],
|
||||
'sender_id': message_data['sender_id'],
|
||||
'timestamp': time.time(),
|
||||
'iv': message_data.get('iv'),
|
||||
'hash': message_hash
|
||||
})
|
||||
|
||||
self.last_activity = time.time()
|
||||
|
||||
def get_messages(self):
|
||||
with self.lock:
|
||||
return list(self.buffer)
|
||||
|
||||
def get_message_count(self):
|
||||
with self.lock:
|
||||
return len(self.buffer)
|
||||
|
||||
def is_expired(self, timeout_seconds=USER_SESSION_TIMEOUT):
|
||||
return time.time() - self.last_activity > timeout_seconds
|
||||
|
||||
# Security validation functions
|
||||
def is_valid_room_id(room_id):
|
||||
"""Validate room ID format and length"""
|
||||
if not room_id or len(room_id) > MAX_ROOM_ID_LENGTH:
|
||||
return False
|
||||
# Allow only alphanumeric characters and hyphens
|
||||
return re.match(r'^[a-zA-Z0-9\-_]+$', room_id) is not None
|
||||
|
||||
def is_valid_message(encrypted_content, iv=None):
|
||||
"""Validate message format and size"""
|
||||
if not encrypted_content or len(encrypted_content) > MAX_MESSAGE_SIZE:
|
||||
return False
|
||||
if iv and len(iv) > 256: # IV should be reasonable size
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_valid_public_key(public_key):
|
||||
"""Validate public key format"""
|
||||
if not public_key or len(public_key) > 2048: # RSA-2048 base64 encoded
|
||||
return False
|
||||
try:
|
||||
# Basic base64 validation
|
||||
import base64
|
||||
base64.b64decode(public_key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def hash_password(password, salt=None):
|
||||
"""Hash password with salt using PBKDF2"""
|
||||
if salt is None:
|
||||
salt = secrets.token_bytes(32)
|
||||
else:
|
||||
salt = bytes.fromhex(salt)
|
||||
|
||||
hashed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100000)
|
||||
return salt.hex() + hashed.hex()
|
||||
|
||||
def verify_password(password, hashed):
|
||||
"""Verify password against hash"""
|
||||
try:
|
||||
salt = bytes.fromhex(hashed[:64])
|
||||
stored_hash = hashed[64:]
|
||||
new_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100000).hex()
|
||||
return hmac.compare_digest(stored_hash, new_hash)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def cleanup_expired_rooms():
|
||||
"""Clean up expired rooms and sessions"""
|
||||
current_time = time.time()
|
||||
expired_rooms = []
|
||||
|
||||
for room_id, room_buffer in chat_rooms.items():
|
||||
if room_buffer.is_expired():
|
||||
expired_rooms.append(room_id)
|
||||
|
||||
for room_id in expired_rooms:
|
||||
logger.info(f"Cleaning up expired room: {room_id}")
|
||||
cleanup_room(room_id)
|
||||
|
||||
def cleanup_room(room_id):
|
||||
"""Completely clean up a room"""
|
||||
chat_rooms.pop(room_id, None)
|
||||
room_keys.pop(room_id, None)
|
||||
room_passwords.pop(room_id, None)
|
||||
room_creation_times.pop(room_id, None)
|
||||
message_hashes.pop(room_id, None)
|
||||
room_session_keys.pop(room_id, None) # Clean up session keys
|
||||
|
||||
def get_client_ip():
|
||||
"""Get real client IP address"""
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
elif request.headers.get('X-Real-IP'):
|
||||
return request.headers.get('X-Real-IP')
|
||||
return request.remote_addr
|
||||
|
||||
# Authentication decorator
|
||||
def require_valid_session(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if request.sid not in user_sessions:
|
||||
logger.warning(f"Invalid session attempt from {get_client_ip()}")
|
||||
disconnect()
|
||||
return
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
# CSP header as requested
|
||||
@app.after_request
|
||||
def apply_csp(response):
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"connect-src 'self' ws://localhost:5000 wss://localhost:5000;"
|
||||
)
|
||||
response.headers["Content-Security-Policy"] = csp
|
||||
return response
|
||||
|
||||
@app.route('/')
|
||||
def hello():
|
||||
htmlbasic= """
|
||||
<body style="background-color: #121212; color: #FFFFFF; font-family: Arial, sans-serif;">
|
||||
"""
|
||||
return htmlbasic + "<p>" + str(todo) + "</p>" + "</body>"
|
||||
def index():
|
||||
return render_template('chat.html')
|
||||
|
||||
@app.route('/room/<room_id>')
|
||||
def room(room_id):
|
||||
if not is_valid_room_id(room_id):
|
||||
return "Invalid room ID", 400
|
||||
return render_template('chat.html', room_id=room_id)
|
||||
|
||||
@app.route('/api/room/<room_id>/info')
|
||||
def get_room_info(room_id):
|
||||
if not is_valid_room_id(room_id):
|
||||
return jsonify({'error': 'Invalid room ID'}), 400
|
||||
|
||||
if room_id in chat_rooms:
|
||||
return jsonify({
|
||||
'exists': True,
|
||||
'message_count': chat_rooms[room_id].get_message_count(),
|
||||
'max_messages': MAX_MESSAGES,
|
||||
'user_count': len(room_keys.get(room_id, {})),
|
||||
'max_users': MAX_USERS_PER_ROOM
|
||||
})
|
||||
return jsonify({'exists': False})
|
||||
|
||||
# Socket event handlers
|
||||
@socketio.on('connect')
|
||||
def handle_connect():
|
||||
client_ip = get_client_ip()
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
# Check connection limits
|
||||
active_connections = sum(1 for session in user_sessions.values()
|
||||
if session.get('ip') == client_ip)
|
||||
|
||||
if active_connections > 10: # Max 10 connections per IP
|
||||
logger.warning(f"Too many connections from IP: {client_ip}")
|
||||
disconnect()
|
||||
return
|
||||
|
||||
user_sessions[request.sid] = {
|
||||
'user_id': user_id,
|
||||
'room': None,
|
||||
'display_name': f"User-{user_id[:8]}",
|
||||
'ip': client_ip,
|
||||
'connected_at': time.time(),
|
||||
'last_activity': time.time()
|
||||
}
|
||||
|
||||
emit('user_connected', {'user_id': user_id})
|
||||
logger.info(f"User {user_id} connected from {client_ip}")
|
||||
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
if request.sid in user_sessions:
|
||||
user_data = user_sessions[request.sid]
|
||||
room_id = user_data.get('room')
|
||||
user_id = user_data.get('user_id')
|
||||
|
||||
if room_id:
|
||||
leave_room(room_id)
|
||||
# Remove user's public key
|
||||
if room_id in room_keys and user_id in room_keys[room_id]:
|
||||
del room_keys[room_id][user_id]
|
||||
|
||||
emit('user_left', {'user_id': user_id}, room=room_id)
|
||||
|
||||
# Clean up empty rooms
|
||||
if room_id in room_keys and len(room_keys[room_id]) == 0:
|
||||
cleanup_room(room_id)
|
||||
|
||||
del user_sessions[request.sid]
|
||||
logger.info(f"User {user_id} disconnected")
|
||||
|
||||
@socketio.on('join_room')
|
||||
@require_valid_session
|
||||
def handle_join_room(data):
|
||||
try:
|
||||
room_id = data.get('room_id', '').strip()
|
||||
public_key = data.get('public_key', '')
|
||||
password = data.get('password', '')
|
||||
|
||||
# Validation
|
||||
if not is_valid_room_id(room_id):
|
||||
emit('room_joined', {'error': 'Invalid room ID format'})
|
||||
return
|
||||
|
||||
if not is_valid_public_key(public_key):
|
||||
emit('room_joined', {'error': 'Invalid public key format'})
|
||||
return
|
||||
|
||||
client_ip = get_client_ip()
|
||||
user_id = user_sessions[request.sid]['user_id']
|
||||
|
||||
is_first_user = False
|
||||
|
||||
# Check if room exists
|
||||
if room_id not in chat_rooms:
|
||||
# Check room creation limits per IP
|
||||
rooms_by_ip = ip_room_count.get(client_ip, 0)
|
||||
if rooms_by_ip >= MAX_ROOMS_PER_IP:
|
||||
emit('room_joined', {'error': 'Too many rooms created from this IP'})
|
||||
return
|
||||
|
||||
# Create new room
|
||||
chat_rooms[room_id] = CircularMessageBuffer()
|
||||
room_keys[room_id] = {}
|
||||
room_creation_times[room_id] = time.time()
|
||||
message_hashes[room_id] = set()
|
||||
room_session_keys[room_id] = None # Initialize session key storage
|
||||
|
||||
# Store hashed password if provided
|
||||
if password:
|
||||
room_passwords[room_id] = hash_password(password)
|
||||
|
||||
# Update IP room count
|
||||
ip_room_count[client_ip] = rooms_by_ip + 1
|
||||
is_first_user = True
|
||||
|
||||
logger.info(f"Room {room_id} created by {user_id}")
|
||||
else:
|
||||
# Check room capacity
|
||||
if len(room_keys[room_id]) >= MAX_USERS_PER_ROOM:
|
||||
emit('room_joined', {'error': 'Room is full'})
|
||||
return
|
||||
|
||||
# Verify password if room is password protected
|
||||
if room_id in room_passwords:
|
||||
if not password:
|
||||
emit('room_joined', {'error': 'Password required'})
|
||||
return
|
||||
|
||||
# Check failed attempts
|
||||
attempt_key = f"{client_ip}_{room_id}"
|
||||
failed_attempts = failed_password_attempts.get(attempt_key, 0)
|
||||
|
||||
if failed_attempts >= 5: # Max 5 failed attempts
|
||||
emit('room_joined', {'error': 'Too many failed password attempts'})
|
||||
return
|
||||
|
||||
if not verify_password(password, room_passwords[room_id]):
|
||||
failed_password_attempts[attempt_key] = failed_attempts + 1
|
||||
emit('room_joined', {'error': 'Incorrect password'})
|
||||
return
|
||||
else:
|
||||
# Clear failed attempts on success
|
||||
failed_password_attempts.pop(attempt_key, None)
|
||||
|
||||
# Generate new UUID for this room session
|
||||
new_user_id = str(uuid.uuid4())
|
||||
user_sessions[request.sid]['user_id'] = new_user_id
|
||||
user_sessions[request.sid]['display_name'] = f"User-{new_user_id[:8]}"
|
||||
user_sessions[request.sid]['last_activity'] = time.time()
|
||||
|
||||
# Store user's public key
|
||||
room_keys[room_id][new_user_id] = public_key
|
||||
|
||||
# Join the room
|
||||
join_room(room_id)
|
||||
user_sessions[request.sid]['room'] = room_id
|
||||
|
||||
# Get room data
|
||||
messages = chat_rooms[room_id].get_messages()
|
||||
user_keys = room_keys[room_id]
|
||||
|
||||
# Send room joined confirmation
|
||||
emit('room_joined', {
|
||||
'room_id': room_id,
|
||||
'user_id': new_user_id,
|
||||
'display_name': user_sessions[request.sid]['display_name'],
|
||||
'messages': messages,
|
||||
'users': list(user_keys.keys()),
|
||||
'user_keys': user_keys,
|
||||
'message_count': chat_rooms[room_id].get_message_count(),
|
||||
'is_first_user': is_first_user
|
||||
})
|
||||
|
||||
# Notify others about new user
|
||||
emit('user_joined', {
|
||||
'user_id': new_user_id,
|
||||
'display_name': user_sessions[request.sid]['display_name'],
|
||||
'public_key': public_key
|
||||
}, room=room_id, include_self=False)
|
||||
|
||||
# Handle session key distribution
|
||||
if is_first_user:
|
||||
# First user - they will generate the session key on client side
|
||||
logger.info(f"First user {new_user_id} joined room {room_id} - will generate session key")
|
||||
else:
|
||||
# Not first user - request session key from existing users
|
||||
logger.info(f"User {new_user_id} joined room {room_id} - requesting session key")
|
||||
emit('request_session_key', {
|
||||
'new_user_id': new_user_id,
|
||||
'public_key': public_key
|
||||
}, room=room_id, include_self=False)
|
||||
|
||||
logger.info(f"User {new_user_id} joined room {room_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in join_room: {str(e)}")
|
||||
emit('room_joined', {'error': 'Internal server error'})
|
||||
|
||||
@socketio.on('send_message')
|
||||
@require_valid_session
|
||||
def handle_send_message(data):
|
||||
try:
|
||||
user_data = user_sessions[request.sid]
|
||||
room_id = user_data['room']
|
||||
|
||||
if not room_id or room_id not in chat_rooms:
|
||||
return
|
||||
|
||||
encrypted_content = data.get('encrypted_content', '')
|
||||
iv = data.get('iv', '')
|
||||
|
||||
# Validate message
|
||||
if not is_valid_message(encrypted_content, iv):
|
||||
logger.warning(f"Invalid message from user {user_data['user_id']}")
|
||||
return
|
||||
|
||||
# Check for duplicate messages
|
||||
message_hash = hashlib.sha256(
|
||||
f"{encrypted_content}{user_data['user_id']}{iv}".encode()
|
||||
).hexdigest()
|
||||
|
||||
if room_id not in message_hashes:
|
||||
message_hashes[room_id] = set()
|
||||
|
||||
if message_hash in message_hashes[room_id]:
|
||||
logger.warning(f"Duplicate message detected from user {user_data['user_id']}")
|
||||
return
|
||||
|
||||
message_hashes[room_id].add(message_hash)
|
||||
|
||||
# Limit message hash storage
|
||||
if len(message_hashes[room_id]) > MAX_MESSAGES * 2:
|
||||
message_hashes[room_id] = set(list(message_hashes[room_id])[-MAX_MESSAGES:])
|
||||
|
||||
message_data = {
|
||||
'encrypted_content': encrypted_content,
|
||||
'sender_id': user_data['user_id'],
|
||||
'display_name': user_data['display_name'],
|
||||
'iv': iv
|
||||
}
|
||||
|
||||
# Add to room buffer
|
||||
chat_rooms[room_id].add_message(message_data)
|
||||
|
||||
# Update user activity
|
||||
user_sessions[request.sid]['last_activity'] = time.time()
|
||||
|
||||
# Broadcast to room
|
||||
emit('new_message', {
|
||||
'id': str(uuid.uuid4()),
|
||||
'encrypted_content': encrypted_content,
|
||||
'sender_id': user_data['user_id'],
|
||||
'display_name': user_data['display_name'],
|
||||
'timestamp': time.time(),
|
||||
'iv': iv,
|
||||
'message_count': chat_rooms[room_id].get_message_count()
|
||||
}, room=room_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in send_message: {str(e)}")
|
||||
|
||||
@socketio.on('share_session_key')
|
||||
@require_valid_session
|
||||
def handle_share_session_key(data):
|
||||
"""Handle sharing of session key with a specific user"""
|
||||
try:
|
||||
room_id = data.get('room_id', '')
|
||||
target_user_id = data.get('target_user_id', '')
|
||||
encrypted_key = data.get('encrypted_key', '')
|
||||
|
||||
# Validation
|
||||
if not room_id or not target_user_id or not encrypted_key:
|
||||
logger.warning("Invalid share_session_key request")
|
||||
return
|
||||
|
||||
if len(encrypted_key) > 2048: # Reasonable limit for encrypted session key
|
||||
logger.warning("Encrypted key too large")
|
||||
return
|
||||
|
||||
sender_id = user_sessions[request.sid]['user_id']
|
||||
sender_room = user_sessions[request.sid].get('room')
|
||||
|
||||
# Verify sender is in the correct room
|
||||
if room_id != sender_room:
|
||||
logger.warning(f"User {sender_id} tried to share key for room {room_id} but is in {sender_room}")
|
||||
return
|
||||
|
||||
# Find target user's socket session
|
||||
target_sid = None
|
||||
for sid, session in user_sessions.items():
|
||||
if session.get('user_id') == target_user_id and session.get('room') == room_id:
|
||||
target_sid = sid
|
||||
break
|
||||
|
||||
if target_sid:
|
||||
# Send the encrypted session key directly to the target user
|
||||
emit('session_key_received', {
|
||||
'from_user_id': sender_id,
|
||||
'encrypted_key': encrypted_key
|
||||
}, room=target_sid)
|
||||
logger.info(f"Session key shared from {sender_id} to {target_user_id} in room {room_id}")
|
||||
else:
|
||||
logger.warning(f"Target user {target_user_id} not found in room {room_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in share_session_key: {str(e)}")
|
||||
|
||||
@socketio.on('key_exchange')
|
||||
@require_valid_session
|
||||
def handle_key_exchange(data):
|
||||
"""Legacy key exchange handler - redirects to share_session_key"""
|
||||
try:
|
||||
# Map old format to new format
|
||||
room_id = data.get('room_id', '')
|
||||
target_user = data.get('target_user', '')
|
||||
encrypted_key = data.get('encrypted_key', '')
|
||||
|
||||
if room_id and target_user and encrypted_key:
|
||||
handle_share_session_key({
|
||||
'room_id': room_id,
|
||||
'target_user_id': target_user,
|
||||
'encrypted_key': encrypted_key
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in legacy key_exchange: {str(e)}")
|
||||
|
||||
# Background cleanup task
|
||||
def start_cleanup_task():
|
||||
def cleanup_worker():
|
||||
while True:
|
||||
try:
|
||||
cleanup_expired_rooms()
|
||||
time.sleep(ROOM_CLEANUP_INTERVAL)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in cleanup task: {str(e)}")
|
||||
|
||||
cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True)
|
||||
cleanup_thread.start()
|
||||
|
||||
# Error handlers
|
||||
@app.errorhandler(429)
|
||||
def ratelimit_handler(e):
|
||||
return jsonify({'error': 'Rate limit exceeded'}), 429
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
logger.error(f"Internal server error: {str(e)}")
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
socketio.run(app, debug=True, allow_unsafe_werkzeug=True)
|
||||
except BrokenPipeError:
|
||||
# Suppress noisy broken pipe errors (client disconnects)
|
||||
import sys
|
||||
print("Broken pipe error suppressed.", file=sys.stderr)
|
||||
0
src/static/script.js
Normal file
0
src/static/script.js
Normal file
437
src/static/styles.css
Normal file
437
src/static/styles.css
Normal file
@@ -0,0 +1,437 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #ececec;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
background: #212121;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
background: #0f0f0f;
|
||||
border-bottom: 1px solid #333;
|
||||
padding: 16px 20px;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.encryption-badge {
|
||||
background: #10a37f;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.room-section {
|
||||
background: #2d2d2d;
|
||||
padding: 20px;
|
||||
margin: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
|
||||
.room-input-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.room-input {
|
||||
flex: 1;
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #404040;
|
||||
color: #ececec;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.room-input:focus {
|
||||
border-color: #10a37f;
|
||||
box-shadow: 0 0 0 2px rgba(16, 163, 127, 0.1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #10a37f;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #0d8c6a;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #3d3d3d;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: #b4b4b4;
|
||||
font-size: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.room-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #2d2d2d;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #b4b4b4;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.room-details {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background: #212121;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb {
|
||||
background: #404040;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
.message-group {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-group.sent {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-group.received {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(45deg, #10a37f, #0d8c6a);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.user-avatar.own {
|
||||
background: linear-gradient(45deg, #6366f1, #8b5cf6);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #ececec;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
.message-content {
|
||||
background: #2d2d2d;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
margin-left: 36px;
|
||||
border: 1px solid #404040;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
max-width: 70%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.message-group.sent .message-content {
|
||||
background: #0f0f0f;
|
||||
border-color: #333;
|
||||
margin-left: 0;
|
||||
margin-right: 36px;
|
||||
text-align: right;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.message-group.received .message-content {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
background: #0f0f0f;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
width: 100%;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #404040;
|
||||
color: #ececec;
|
||||
padding: 16px 60px 16px 20px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
min-height: 24px;
|
||||
max-height: 200px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.message-input:focus {
|
||||
border-color: #10a37f;
|
||||
box-shadow: 0 0 0 2px rgba(16, 163, 127, 0.1);
|
||||
}
|
||||
|
||||
.message-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: #10a37f;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.send-button:hover:not(:disabled) {
|
||||
background: #0d8c6a;
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
background: #404040;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.welcome-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(45deg, #10a37f, #6366f1);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
font-size: 18px;
|
||||
color: #b4b4b4;
|
||||
margin-bottom: 32px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 32px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
background: #2d2d2d;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #ececec;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
color: #b4b4b4;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #b4b4b4;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #404040;
|
||||
border-top: 2px solid #10a37f;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.room-input-container {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.room-input, .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.room-details {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
margin-left: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
1560
src/templates/chat.html
Normal file
1560
src/templates/chat.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user