diff --git a/app.py b/app.py new file mode 100644 index 0000000..b8d0785 --- /dev/null +++ b/app.py @@ -0,0 +1,94 @@ +"""Main application entry point with Flask setup and Blueprint registration.""" +import os +from flask import Flask, render_template, send_file, make_response, request +from flask_cors import CORS +from flask_socketio import SocketIO +from database import init_db, close_db +from blueprints.auth import auth_bp +from blueprints.users import users_bp +from blueprints.chat import chat_bp +from blueprints.files import files_bp +from blueprints.admin import admin_bp +from blueprints.dev import dev_bp + +app = Flask(__name__) +CORS(app, supports_credentials=True) + +# Configuration +app.config['DATABASE'] = '../chat.db' +app.config['SECRET_KEY'] = 'k68OAhbpLpEXn7EqiMJRV3yLf3qxrR25SY8UIGy10sKhAKzpZseZs94jVddbU6Q8XYmbiS0Q7nmeWLkb7Bwm8lwA/A29h8MsKOJayxKYq/fKkOrElUbj14LUJhSuJHA' +app.config['VERSION'] = "0.24 Modular Architecture" + +socketio = SocketIO(app) +app.extensions['socketio'] = socketio + +# Store connected clients for stats +connected_clients = set() +app.extensions['connected_clients'] = connected_clients + + +# SocketIO event handlers +@socketio.on('connect') +def handle_connect(): + """Handle client connection.""" + print(f"SocketIO connect event received from {request.sid}") + connected_clients.add(request.sid) + print(f"Client connected: {request.sid}") + return True + + +@socketio.on('disconnect') +def handle_disconnect(): + """Handle client disconnection.""" + print(f"SocketIO disconnect event received from {request.sid}") + connected_clients.discard(request.sid) + print(f"Client disconnected: {request.sid}") + + +# Database setup +def setup_database(app): + """Initialize database and register teardown.""" + init_db(app) + app.teardown_appcontext(lambda error: close_db(app, error)) + + +# Register Blueprints +def register_blueprints(app): + """Register all application blueprints.""" + app.register_blueprint(auth_bp) + app.register_blueprint(users_bp) + app.register_blueprint(chat_bp) + app.register_blueprint(files_bp) + app.register_blueprint(admin_bp) + app.register_blueprint(dev_bp) + + +# Template routes +@app.route('/') +def index(): + """Main index page.""" + return render_template('index.html') + + +@app.route('/sw.js') +def service_worker(): + """Service worker script.""" + with open(os.path.join(current_app.root_path, 'sw.js'), 'r') as f: + resp = make_response(f.read()) + resp.headers['Content-Type'] = 'application/javascript' + return resp + + +# Application initialization +def create_app(): + """Create and configure the Flask application.""" + setup_database(app) + register_blueprints(app) + return app + + +create_app() + + +if __name__ == '__main__': + socketio.run(app, debug=True, host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/blueprints/__init__.py b/blueprints/__init__.py new file mode 100644 index 0000000..c6472a8 --- /dev/null +++ b/blueprints/__init__.py @@ -0,0 +1 @@ +"""Blueprints package initialization.""" diff --git a/blueprints/admin.py b/blueprints/admin.py new file mode 100644 index 0000000..044ac51 --- /dev/null +++ b/blueprints/admin.py @@ -0,0 +1,116 @@ +"""Admin routes and panel.""" +from flask import Blueprint, render_template, jsonify, request, current_app +from database import get_db +from decorators import token_required, role_required, get_user_badge + +admin_bp = Blueprint('admin', __name__) + + +def get_socketio(): + """Get SocketIO instance from current app context.""" + return current_app.extensions.get('socketio') + + +@admin_bp.route('/api/admin/users', methods=['POST']) +@token_required +@role_required('Admin') +def admin_get_users(): + """Get all users (admin only).""" + db = get_db(current_app) + users = db.execute('SELECT id, username, is_banned FROM users').fetchall() + result = [] + for u in users: + ud = dict(u) + ud['badge'] = get_user_badge(u['username']) + result.append(ud) + return jsonify(result) + + +@admin_bp.route('/api/admin/ban', methods=['POST']) +@token_required +@role_required('Admin') +def admin_ban(): + """Ban or unban a user (admin only).""" + data = request.json + user_id, status = data.get('user_id'), int(data.get('ban', 1)) + db = get_db(current_app) + db.execute('UPDATE users SET is_banned = ? WHERE id = ?', (status, user_id)) + db.commit() + + socketio = get_socketio() + if socketio: + socketio.emit('user_ban_status', {'user_id': user_id, 'is_banned': status}) + + return jsonify({'status': 'updated'}) + + +@admin_bp.route('/api/admin/servers', methods=['POST']) +@token_required +@role_required('Admin') +def admin_get_servers(): + """Get all servers (admin only).""" + return jsonify([dict(s) for s in get_db(current_app).execute('SELECT id, name FROM servers').fetchall()]) + + +@admin_bp.route('/api/admin/channels', methods=['POST']) +@token_required +@role_required('Admin') +def admin_get_channels(): + """Get channels in a server (admin only).""" + sid = request.json.get('server_id') + return jsonify([dict(c) for c in get_db(current_app).execute( + 'SELECT id, name FROM channels WHERE server_id = ?', (sid,) + ).fetchall()]) + + +@admin_bp.route('/api/admin/messages', methods=['POST']) +@token_required +@role_required('Admin') +def admin_get_messages(): + """Get messages in a channel (admin only).""" + cid = request.json.get('channel_id') + msgs = get_db(current_app).execute( + 'SELECT m.id, m.content, m.timestamp, u.username FROM messages m JOIN users u ON m.sender_id = u.id WHERE m.channel_id = ? ORDER BY m.timestamp DESC LIMIT 100', + (cid,) + ).fetchall() + return jsonify([dict(m) for m in msgs]) + + +@admin_bp.route('/api/admin/bulk-delete', methods=['POST']) +@token_required +@role_required('Admin') +def admin_bulk_delete(): + """Bulk delete messages (admin only).""" + ids = request.json.get('message_ids', []) + db = get_db(current_app) + for mid in ids: + db.execute('DELETE FROM messages WHERE id = ?', (mid,)) + socketio = get_socketio() + if socketio: + socketio.emit('delete_message', {'id': mid}) + db.commit() + return jsonify({'deleted': len(ids)}) + + +@admin_bp.route('/api/admin/message/', methods=['DELETE']) +@token_required +@role_required('Admin') +def admin_delete_message(msg_id): + """Delete a single message (admin only).""" + db = get_db(current_app) + db.execute('DELETE FROM messages WHERE id = ?', (msg_id,)) + db.commit() + + socketio = get_socketio() + if socketio: + socketio.emit('delete_message', {'id': msg_id}) + + return jsonify({'status': 'deleted'}) + + +@admin_bp.route('/ihazadmin') +@token_required +@role_required('Admin') +def ihazadmin(): + """Admin panel page.""" + return render_template('admin.html') diff --git a/blueprints/auth.py b/blueprints/auth.py new file mode 100644 index 0000000..3ba1241 --- /dev/null +++ b/blueprints/auth.py @@ -0,0 +1,61 @@ +"""Authentication routes (register, login).""" +import secrets +import sqlite3 +from flask import Blueprint, jsonify, make_response, request, current_app +from werkzeug.security import check_password_hash, generate_password_hash +from database import get_db +from decorators import rate_limit + +auth_bp = Blueprint('auth', __name__, url_prefix='/api') + + +@auth_bp.route('/register', methods=['POST']) +@rate_limit(max_attempts=5, window=3600) # 5 attempts per hour +def register(): + """Register a new user with invite code.""" + data = request.json + username = data.get('username') + password = data.get('password') + invite_code = data.get('invite_code') + if not username or not password or not invite_code: + return jsonify({'error': 'Username, password, and invite_code required'}), 400 + + db = get_db(current_app) + code_row = db.execute('SELECT id FROM registration_codes WHERE code = ? AND is_used = 0', (invite_code,)).fetchone() + if not code_row: + return jsonify({'error': 'Invalid or already used invite code'}), 400 + + hashed_password = generate_password_hash(password) + token = secrets.token_hex(32) + try: + cur = db.execute('INSERT INTO users (username, password, token, avatar_url, description, pronouns) VALUES (?, ?, ?, ?, ?, ?)', + (username, hashed_password, token, '', '', '')) + db.execute('UPDATE registration_codes SET is_used = 1 WHERE code = ?', (invite_code,)) + db.commit() + resp = make_response(jsonify({'token': token, 'user_id': cur.lastrowid, 'username': username}), 201) + resp.set_cookie('auth_token', token, httponly=True, samesite='Lax', max_age=86400) + return resp + except sqlite3.IntegrityError: + db.rollback() + return jsonify({'error': 'Username already exists'}), 409 + + +@auth_bp.route('/login', methods=['POST']) +@rate_limit(max_attempts=10, window=3600) # 10 attempts per hour +def login(): + """Log in a user.""" + data = request.json + username = data.get('username') + password = data.get('password') + if not username or not password: + return jsonify({'error': 'Username and password required'}), 400 + + user = get_db(current_app).execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone() + if not user or not check_password_hash(user['password'], password): + return jsonify({'error': 'Invalid credentials'}), 401 + if user['is_banned'] == 1: + return jsonify({'error': 'Account banned'}), 403 + + resp = make_response(jsonify({'token': user['token'], 'user_id': user['id'], 'username': user['username']}), 200) + resp.set_cookie('auth_token', user['token'], httponly=True, samesite='Lax', max_age=86400) + return resp diff --git a/blueprints/chat.py b/blueprints/chat.py new file mode 100644 index 0000000..598e365 --- /dev/null +++ b/blueprints/chat.py @@ -0,0 +1,174 @@ +"""Chat routes (messages, channels, servers, DMs).""" +import json +from flask import Blueprint, g, jsonify, request, current_app +from database import get_db +from decorators import token_required, get_user_badge + +chat_bp = Blueprint('chat', __name__, url_prefix='/api') + + +def get_socketio(): + """Get SocketIO instance from current app context.""" + return current_app.extensions.get('socketio') + + +@chat_bp.route('/messages', methods=['GET']) +@token_required +def get_messages(): + """Get messages from a channel or global chat.""" + channel_id = request.args.get('channel_id', type=int) + db = get_db(current_app) + base_query = ''' + SELECT m.id, m.sender_id, u.username, u.avatar_url, u.pronouns, m.content, + m.timestamp, m.channel_id, m.receiver_id, m.is_global, m.reply_to + FROM messages m + JOIN users u ON m.sender_id = u.id + ''' + if channel_id: + query = base_query + ' WHERE m.channel_id = ? ORDER BY m.timestamp DESC LIMIT 50' + messages = db.execute(query, (channel_id,)).fetchall() + else: + query = base_query + ' WHERE m.is_global = 1 ORDER BY m.timestamp DESC LIMIT 50' + messages = db.execute(query).fetchall() + result = [] + for m in messages: + msg_dict = dict(m) + msg_dict['badge'] = get_user_badge(m['username']) + result.append(msg_dict) + return jsonify([dict(m) for m in reversed(result)]), 200 + + +@chat_bp.route('/messages', methods=['POST']) +@token_required +def post_message(): + """Post a message to global chat, channel, or DM.""" + content = None + receiver_id = None + channel_id = None + reply_to = None + file_info = None + + if request.content_type and request.content_type.startswith('multipart/form-data'): + content = request.form.get('content') + receiver_id = request.form.get('receiver_id', type=int) + channel_id = request.form.get('channel_id', type=int) + reply_to = request.form.get('reply_to', type=int) + if 'file' in request.files and request.files['file'].filename: + try: + from blueprints.files import process_file_upload + file_info = process_file_upload(request.files['file'], current_app) + except ValueError: + return jsonify({'error': "You can't upload big files, if you need more files, contact dev and buy your own disk space on server"}), 400 + else: + data = request.json or {} + content = data.get('content') + receiver_id = data.get('receiver_id') + channel_id = data.get('channel_id') + reply_to = data.get('reply_to') + + if file_info: + content = json.dumps({'file': file_info, 'text': content or ''}) + if not content: + return jsonify({'error': 'Content required'}), 400 + + db = get_db(current_app) + is_global = 1 if (receiver_id is None and not channel_id) else 0 + cur = db.execute( + 'INSERT INTO messages (sender_id, receiver_id, channel_id, content, is_global, reply_to) VALUES (?, ?, ?, ?, ?, ?)', + (g.user['id'], receiver_id, channel_id, content, is_global, reply_to) + ) + db.commit() + message_id = cur.lastrowid + message = db.execute(''' + SELECT m.id, m.sender_id, u.username, u.avatar_url, u.pronouns, m.content, + m.timestamp, m.channel_id, m.receiver_id, m.is_global, m.reply_to + FROM messages m + JOIN users u ON m.sender_id = u.id + WHERE m.id = ? + ''', (message_id,)).fetchone() + message_dict = dict(message) + message_dict['badge'] = get_user_badge(message['username']) + + socketio = get_socketio() + if socketio: + socketio.emit('new_message', message_dict) + + return jsonify({'id': message_id, 'status': 'sent'}), 201 + + +@chat_bp.route('/servers', methods=['GET']) +@token_required +def get_servers(): + """Get all servers and their channels.""" + db = get_db(current_app) + servers = db.execute('SELECT id, name FROM servers').fetchall() + result = [] + for s in servers: + channels = db.execute('SELECT id, name FROM channels WHERE server_id = ?', (s['id'],)).fetchall() + result.append({ + 'id': s['id'], + 'name': s['name'], + 'channels': [{'id': c['id'], 'name': c['name']} for c in channels] + }) + return jsonify(result), 200 + + +@chat_bp.route('/servers', methods=['POST']) +@token_required +def create_server(): + """Create a new server.""" + data = request.json + name = data.get('name') + if not name: + return jsonify({'error': 'Server name required'}), 400 + db = get_db(current_app) + try: + cur = db.execute('INSERT INTO servers (name) VALUES (?)', (name,)) + server_id = cur.lastrowid + db.execute('INSERT INTO channels (server_id, name) VALUES (?, ?)', (server_id, 'general')) + db.commit() + return jsonify({'id': server_id, 'name': name}), 201 + except Exception: + return jsonify({'error': 'Server already exists'}), 409 + + +@chat_bp.route('/servers//channels', methods=['POST']) +@token_required +def create_channel(server_id): + """Create a new channel in a server.""" + data = request.json + name = data.get('name') + if not name: + return jsonify({'error': 'Channel name required'}), 400 + db = get_db(current_app) + cur = db.execute('INSERT INTO channels (server_id, name) VALUES (?, ?)', (server_id, name)) + db.commit() + return jsonify({'id': cur.lastrowid, 'name': name}), 201 + + +@chat_bp.route('/dm/', methods=['GET']) +@token_required +def get_dm(user_id): + """Get direct messages with a specific user.""" + since = request.args.get('since') + db = get_db(current_app) + query = ''' + SELECT m.id, m.sender_id, u.username, u.avatar_url, u.pronouns, m.content, + m.timestamp, m.channel_id, m.receiver_id, m.is_global, m.reply_to + FROM messages m + JOIN users u ON m.sender_id = u.id + WHERE m.is_global = 0 AND ( + (m.sender_id = ? AND m.receiver_id = ?) OR + (m.sender_id = ? AND m.receiver_id = ?) + ) + ''' + params = [g.user['id'], user_id, user_id, g.user['id']] + if since: + query += ' AND m.timestamp > ?' + params.append(since) + query += ' ORDER BY m.timestamp DESC LIMIT 50' + messages = db.execute(query, tuple(params)).fetchall() + result = [dict(m) for m in messages] + for m in result: + m['badge'] = get_user_badge(m['username']) + return jsonify(list(reversed(result))), 200 diff --git a/blueprints/dev.py b/blueprints/dev.py new file mode 100644 index 0000000..4259095 --- /dev/null +++ b/blueprints/dev.py @@ -0,0 +1,131 @@ +"""Developer routes and panel.""" +import os +import signal +import random +import string +from flask import Blueprint, render_template, jsonify, request, current_app +from database import get_db +from decorators import token_required, role_required + +dev_bp = Blueprint('dev', __name__) + + +def get_socketio(): + """Get SocketIO instance from current app context.""" + return current_app.extensions.get('socketio') + + +def generate_invite_code(custom=None): + """Generate a random invite code or use custom one.""" + if custom: + return custom.upper() + groups = [''.join(random.choices(string.ascii_uppercase, k=size)) for size in [3, 3, 5]] + return '-'.join(groups) + + +@dev_bp.route('/api/dev/system-stats', methods=['POST']) +@token_required +@role_required('Dev') +def dev_system_stats(): + """Get system statistics (dev only).""" + db_path = current_app.config['DATABASE'] + size = os.path.getsize(db_path) if os.path.exists(db_path) else 0 + connected_clients = current_app.extensions.get('connected_clients', set()) + return jsonify({ + 'db_size_mb': round(size / (1024 * 1024), 2), + 'active_users': len(connected_clients), + 'uptime': 'N/A', +'message_count': get_db(current_app).execute('SELECT COUNT(*) FROM messages').fetchone()[0] + }) + + +@dev_bp.route('/api/dev/list-codes', methods=['GET']) +@token_required +@role_required('Dev') +def dev_list_codes(): + """List unused registration codes (dev only).""" + return jsonify([{'code': c['code']} for c in get_db(current_app).execute( + 'SELECT code FROM registration_codes WHERE is_used = 0 ORDER BY id DESC' + ).fetchall()]) + + +@dev_bp.route('/api/dev/generate-code', methods=['POST']) +@token_required +@role_required('Dev') +def dev_generate_code(): + """Generate registration codes (dev only).""" + data = request.json or {} + custom = data.get('custom') + db = get_db(current_app) + if custom: + code = generate_invite_code(custom) + db.execute('INSERT INTO registration_codes (code) VALUES (?)', (code,)) + res = [{'code': code}] + else: + res = [] + for _ in range(data.get('num', 10)): + code = generate_invite_code() + db.execute('INSERT INTO registration_codes (code) VALUES (?)', (code,)) + res.append({'code': code}) + db.commit() + return jsonify(res) + + +@dev_bp.route('/api/dev/global-broadcast', methods=['POST']) +@token_required +@role_required('Dev') +def dev_global_broadcast(): + """Send a system message to all users and channels (dev only).""" + msg = request.json.get('message') + db = get_db(current_app) + for row in db.execute('SELECT id FROM channels').fetchall(): + db.execute('INSERT INTO messages (sender_id, channel_id, content, is_global) VALUES (-1, ?, ?, 0)', + (row['id'], msg)) + for row in db.execute('SELECT id FROM users').fetchall(): + db.execute('INSERT INTO messages (sender_id, receiver_id, content, is_global) VALUES (-1, ?, ?, 0)', + (row['id'], msg)) + db.commit() + + socketio = get_socketio() + if socketio: + socketio.emit('system_notification', {'message': msg, 'type': 'global_broadcast'}) + + return jsonify({'status': 'broadcasted'}) + + +@dev_bp.route('/api/dev/restart', methods=['POST']) +@token_required +@role_required('Dev') +def dev_restart(): + """Trigger application restart using signal (dev only). + + This sends SIGHUP to the current process, which should be caught by the + process manager (e.g., Gunicorn) to gracefully restart the application. + Requires the application to run under a process manager that respects signals. + """ + try: + # Send SIGHUP signal to current process for graceful restart + # The process manager (e.g., Gunicorn) should handle this signal + os.kill(os.getpid(), signal.SIGHUP) + return jsonify({'status': 'restart signal sent'}), 200 + except (OSError, RuntimeError) as e: + return jsonify({'error': f'Failed to send restart signal: {str(e)}'}), 500 + + +@dev_bp.route('/api/dev/broadcast', methods=['POST']) +@token_required +@role_required('Dev') +def dev_broadcast(): + """Send a system notification to all connected clients (dev only).""" + socketio = get_socketio() + if socketio: + socketio.emit('system_notification', {'message': request.json.get('message')}) + return jsonify({'status': 'broadcasted'}) + + +@dev_bp.route('/imdadev') +@token_required +@role_required('Dev') +def imdadev(): + """Dev panel page.""" + return render_template('dev.html') diff --git a/blueprints/files.py b/blueprints/files.py new file mode 100644 index 0000000..aa36ab0 --- /dev/null +++ b/blueprints/files.py @@ -0,0 +1,66 @@ +"""File upload and serving routes.""" +import os +import secrets +from flask import Blueprint, jsonify, send_file, request, current_app +from werkzeug.utils import secure_filename +from database import get_db +from decorators import token_required + +files_bp = Blueprint('files', __name__, url_prefix='/api') + +MAX_UPLOAD_SIZE = 100 * 1024 * 1024 + + +def process_file_upload(file, current_app): + """Upload and store file with secure token-based filename.""" + file_bytes = file.read() + size = len(file_bytes) + if size > MAX_UPLOAD_SIZE: + raise ValueError("File exceeds maximum allowed size.") + upload_dir = os.path.join(current_app.root_path, 'static', 'uploads') + os.makedirs(upload_dir, exist_ok=True) + orig = secure_filename(file.filename) + fname = f"{secrets.token_hex(8)}_{orig}" + path = os.path.join(upload_dir, fname) + with open(path, 'wb') as f: + f.write(file_bytes) + return { + 'filename': file.filename, + 'url': f'/api/files/{fname}', + 'mimetype': file.mimetype, + 'size': size + } + + +@files_bp.route('/files/', methods=['GET']) +@token_required +def serve_file(filename): + """Serve uploaded files with secure headers to prevent execution/rendering.""" + # Validate filename to prevent directory traversal + if '/' in filename or '\\' in filename or filename.startswith('.'): + return jsonify({'error': 'Invalid filename'}), 400 + + file_path = os.path.join(current_app.root_path, 'static', 'uploads', filename) + + # Ensure file exists and is within uploads directory + if not os.path.exists(file_path) or not os.path.isfile(file_path): + return jsonify({'error': 'File not found'}), 404 + + # Verify the file is in the uploads directory + real_path = os.path.realpath(file_path) + uploads_dir = os.path.realpath(os.path.join(current_app.root_path, 'static', 'uploads')) + if not real_path.startswith(uploads_dir): + return jsonify({'error': 'Access denied'}), 403 + + # Serve file with secure headers + response = send_file( + file_path, + as_attachment=True, # Forces download instead of rendering + download_name=filename.split('_', 1)[-1] if '_' in filename else filename + ) + # Additional security headers + response.headers['X-Content-Type-Options'] = 'nosniff' # Prevent MIME type sniffing + response.headers['X-Frame-Options'] = 'DENY' # Prevent clickjacking + response.headers['Content-Security-Policy'] = "default-src 'none'" + + return response diff --git a/blueprints/users.py b/blueprints/users.py new file mode 100644 index 0000000..f0711ec --- /dev/null +++ b/blueprints/users.py @@ -0,0 +1,172 @@ +"""User profile, friends, and user listing routes.""" +import sqlite3 +from flask import Blueprint, g, jsonify, request, current_app +from database import get_db +from decorators import token_required, get_user_badge +from werkzeug.utils import secure_filename +from PIL import Image +import os + +users_bp = Blueprint('users', __name__, url_prefix='/api') + +AVATAR_SIZE = 520 + + +def process_avatar_upload(file, user_id, current_app): + """Process and store avatar file.""" + avatar_dir = os.path.join(current_app.root_path, 'static', 'avatars') + os.makedirs(avatar_dir, exist_ok=True) + img = Image.open(file.stream) + w, h = img.size + side = min(w, h) + left = (w - side) // 2 + top = (h - side) // 2 + img = img.crop((left, top, left + side, top + side)) + if side > AVATAR_SIZE: + img = img.resize((AVATAR_SIZE, AVATAR_SIZE), Image.LANCZOS) + fname = f"avatar_{user_id}.png" + path = os.path.join(avatar_dir, fname) + img.save(path, format='PNG') + return f'/static/avatars/{fname}' + + +@users_bp.route('/users', methods=['GET']) +@token_required +def get_users(): + """Get all users with their basic info.""" + users = get_db(current_app).execute('SELECT id, username FROM users').fetchall() + result = [] + for u in users: + user_dict = {'id': u['id'], 'username': u['username'], 'badge': get_user_badge(u['username'])} + result.append(user_dict) + return jsonify(result), 200 + + +@users_bp.route('/users/', methods=['GET']) +@token_required +def get_user_profile(user_id): + """Get user profile by ID.""" + user = get_db(current_app).execute( + 'SELECT id, username, avatar_url, description, pronouns FROM users WHERE id = ?', + (user_id,) + ).fetchone() + if not user: + return jsonify({'error': 'User not found'}), 404 + user_dict = dict(user) + user_dict['badge'] = get_user_badge(user['username']) + return jsonify(user_dict), 200 + + +@users_bp.route('/profile', methods=['GET']) +@token_required +def get_profile(): + """Get current user's profile.""" + user = g.user.copy() + user.pop('password', None) + user.pop('token', None) + user['badge'] = get_user_badge(user.get('username')) + return jsonify(user), 200 + + +@users_bp.route('/profile', methods=['POST']) +@token_required +def update_profile(): + """Update current user's profile.""" + avatar = None + descr = None + pron = None + + if 'avatar_file' in request.files and request.files['avatar_file'].filename: + avatar = process_avatar_upload(request.files['avatar_file'], g.user['id'], current_app) + + if request.form: + descr = request.form.get('description') + pron = request.form.get('pronouns') + else: + data = request.json or {} + descr = data.get('description') + pron = data.get('pronouns') + avatar = avatar or data.get('avatar_url') + + # Preserve existing avatar if no new one provided + current_avatar = g.user.get('avatar_url', '') + final_avatar = avatar if avatar is not None else current_avatar + + db = get_db(current_app) + db.execute('UPDATE users SET avatar_url = ?, description = ?, pronouns = ? WHERE id = ?', + (final_avatar, descr or '', pron or '', g.user['id'])) + db.commit() + user = db.execute('SELECT id, username, avatar_url, description, pronouns FROM users WHERE id = ?', + (g.user['id'],)).fetchone() + return jsonify(dict(user)), 200 + + +@users_bp.route('/friends', methods=['GET']) +@token_required +def get_friends(): + """Get current user's accepted friends.""" + db = get_db(current_app) + friends = db.execute(''' + SELECT u.id, u.username, u.avatar_url, u.pronouns FROM friends f + JOIN users u ON f.friend_id = u.id WHERE f.user_id = ? AND f.status = 'accepted' + UNION + SELECT u.id, u.username, u.avatar_url, u.pronouns FROM friends f + JOIN users u ON f.user_id = u.id WHERE f.friend_id = ? AND f.status = 'accepted' + ''', (g.user['id'], g.user['id'])).fetchall() + result = [] + for f in friends: + fd = dict(f) + fd['badge'] = get_user_badge(f['username']) + result.append(fd) + return jsonify(result), 200 + + +@users_bp.route('/friend-requests', methods=['GET']) +@token_required +def get_friend_requests(): + """Get pending friend requests for current user.""" + reqs = get_db(current_app).execute( + 'SELECT f.id, u.id as user_id, u.username FROM friends f JOIN users u ON f.user_id = u.id WHERE f.friend_id = ? AND f.status = "pending"', + (g.user['id'],) + ).fetchall() + return jsonify([dict(r) for r in reqs]), 200 + + +@users_bp.route('/friend-request', methods=['POST']) +@token_required +def send_friend_request(): + """Send a friend request to another user.""" + target_username = request.json.get('username') + if not target_username: + return jsonify({'error': 'Username required'}), 400 + + db = get_db(current_app) + target = db.execute('SELECT id FROM users WHERE username = ?', (target_username,)).fetchone() + if not target: + return jsonify({'error': 'User not found'}), 404 + if target['id'] == g.user['id']: + return jsonify({'error': 'Cannot add yourself'}), 400 + + try: + db.execute("INSERT INTO friends (user_id, friend_id, status) VALUES (?, ?, 'pending')", + (g.user['id'], target['id'])) + db.commit() + return jsonify({'status': 'sent'}), 201 + except sqlite3.IntegrityError: + return jsonify({'error': 'Friend request already exists'}), 409 + + +@users_bp.route('/friend-request/', methods=['POST']) +@token_required +def respond_friend_request(request_id): + """Accept or reject a friend request.""" + action = request.json.get('action') + db = get_db(current_app) + if action == 'accept': + db.execute("UPDATE friends SET status = 'accepted' WHERE id = ? AND friend_id = ?", + (request_id, g.user['id'])) + elif action == 'reject': + db.execute("DELETE FROM friends WHERE id = ? AND friend_id = ?", + (request_id, g.user['id'])) + db.commit() + return jsonify({'status': 'updated'}), 200 diff --git a/database.py b/database.py new file mode 100644 index 0000000..bda522c --- /dev/null +++ b/database.py @@ -0,0 +1,111 @@ +"""Database initialization and connection management.""" +import sqlite3 +from flask import g + + +def get_db(app): + """Get database connection from Flask app context.""" + if 'db' not in g: + g.db = sqlite3.connect(app.config['DATABASE']) + g.db.row_factory = sqlite3.Row + return g.db + + +def close_db(app, error=None): + """Close database connection on app teardown.""" + db = g.pop('db', None) + if db is not None: + db.close() + + +def init_db(app): + """Initialize database with all required tables and columns. + + Uses parameterized queries and whitelist approach for all dynamic values + to prevent SQL injection. + """ + db = sqlite3.connect(app.config['DATABASE']) + db.execute('''CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + token TEXT UNIQUE NOT NULL + )''') + db.execute('''CREATE TABLE IF NOT EXISTS registration_codes ( + id INTEGER PRIMARY KEY, + code TEXT UNIQUE NOT NULL, + is_used INTEGER DEFAULT 0 + )''') + + # Whitelist of allowed columns to add to users table + # Format: column_name -> SQL definition + users_columns_whitelist = { + 'avatar_url': 'TEXT', + 'description': 'TEXT', + 'pronouns': 'TEXT', + 'is_banned': 'INTEGER DEFAULT 0' + } + + cols = [row[1] for row in db.execute('PRAGMA table_info(users)').fetchall()] + + # Add missing columns using explicit ALTER TABLE statements + # (SQLite does not support parameterized column/table names) + for col_name in users_columns_whitelist: + if col_name not in cols: + try: + col_type = users_columns_whitelist[col_name] + db.execute(f'ALTER TABLE users ADD COLUMN {col_name} {col_type}') + except sqlite3.OperationalError: + pass + + db.execute('''CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + sender_id INTEGER NOT NULL, + receiver_id INTEGER, + content TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + is_global INTEGER DEFAULT 1, + FOREIGN KEY(sender_id) REFERENCES users(id) + )''') + + # Whitelist of allowed columns to add to messages table + messages_columns_whitelist = { + 'channel_id': 'INTEGER', + 'reply_to': 'INTEGER' + } + + cols = [row[1] for row in db.execute('PRAGMA table_info(messages)').fetchall()] + for col_name in messages_columns_whitelist: + if col_name not in cols: + try: + col_type = messages_columns_whitelist[col_name] + db.execute(f'ALTER TABLE messages ADD COLUMN {col_name} {col_type}') + except sqlite3.OperationalError: + pass + + db.execute('''CREATE TABLE IF NOT EXISTS friends ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + friend_id INTEGER NOT NULL, + status TEXT DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id), + FOREIGN KEY(friend_id) REFERENCES users(id), + UNIQUE(user_id, friend_id) + )''') + db.execute('''CREATE TABLE IF NOT EXISTS servers ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL + )''') + db.execute('''CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY, + server_id INTEGER NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY(server_id) REFERENCES servers(id) + )''') + cur = db.execute('SELECT id FROM servers WHERE id=1') + if not cur.fetchone(): + db.execute('INSERT INTO servers (id, name) VALUES (1, ?)', ('Это днище интернета 2',)) + db.execute('INSERT INTO channels (server_id, name) VALUES (1, ?)', ('general',)) + db.commit() + db.close() diff --git a/db.py b/db.py new file mode 100644 index 0000000..555c8e0 --- /dev/null +++ b/db.py @@ -0,0 +1,99 @@ +import json +from pathlib import Path +from typing import Any, Dict, List, Optional + + +class MessengerDBManager: + def __init__(self, base_path: str = "data") -> None: + self.base_path = Path(base_path) + self._initialize_structure() + + def _initialize_structure(self) -> None: + (self.base_path / "dms").mkdir(parents=True, exist_ok=True) + (self.base_path / "servers").mkdir(parents=True, exist_ok=True) + (self.base_path / "users").mkdir(parents=True, exist_ok=True) + + self._ensure_file(self.base_path / "users" / "tags.json", {}) + self._ensure_file(self.base_path / "users" / "users.json", {}) + + def _ensure_file(self, path: Path, default_content: Any) -> None: + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + self._write_json(path, default_content) + + def _read_json(self, path: Path) -> Any: + if not path.exists(): + return None + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + def _write_json(self, path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + + def _get_dm_filename(self, user1: str, user2: str) -> str: + users = sorted([user1, user2]) + return f"{users[0]}-{users[1]}_encrypted.json" + + def read_dm(self, user1: str, user2: str) -> Optional[List[Dict[str, Any]]]: + path = self.base_path / "dms" / self._get_dm_filename(user1, user2) + return self._read_json(path) + + def write_dm(self, user1: str, user2: str, messages: List[Dict[str, Any]]) -> None: + path = self.base_path / "dms" / self._get_dm_filename(user1, user2) + self._write_json(path, messages) + + def read_users(self) -> Dict[str, Any]: + return self._read_json(self.base_path / "users" / "users.json") or {} + + def write_users(self, data: Dict[str, Any]) -> None: + self._write_json(self.base_path / "users" / "users.json", data) + + def read_tags(self) -> Dict[str, Any]: + return self._read_json(self.base_path / "users" / "tags.json") or {} + + def write_tags(self, data: Dict[str, Any]) -> None: + self._write_json(self.base_path / "users" / "tags.json", data) + + def _get_server_path(self, server_id: str) -> Path: + return self.base_path / "servers" / str(server_id) + + def init_server(self, server_id: str) -> None: + server_path = self._get_server_path(server_id) + self._ensure_file(server_path / "banned_ppl.json", []) + self._ensure_file(server_path / "members.json", []) + self._ensure_file(server_path / "roles.json", {}) + self._ensure_file(server_path / "server_settings.json", {}) + + def read_server_file(self, server_id: str, filename: str) -> Any: + path = self._get_server_path(server_id) / f"{filename}.json" + return self._read_json(path) + + def write_server_file(self, server_id: str, filename: str, data: Any) -> None: + path = self._get_server_path(server_id) / f"{filename}.json" + self._write_json(path, data) + + def _get_channel_path(self, server_id: str, channel_id: str) -> Path: + return self._get_server_path(server_id) / "channels" / str(channel_id) + + def init_channel(self, server_id: str, channel_id: str) -> None: + channel_path = self._get_channel_path(server_id, channel_id) + self._ensure_file(channel_path / "messages.json", []) + self._ensure_file(channel_path / "channel_config.json", {}) + + def read_channel_messages(self, server_id: str, channel_id: str) -> Optional[List[Dict[str, Any]]]: + path = self._get_channel_path(server_id, channel_id) / "messages.json" + return self._read_json(path) + + def write_channel_messages(self, server_id: str, channel_id: str, messages: List[Dict[str, Any]]) -> None: + path = self._get_channel_path(server_id, channel_id) / "messages.json" + self._write_json(path, messages) + + def read_channel_config(self, server_id: str, channel_id: str) -> Optional[Dict[str, Any]]: + path = self._get_channel_path(server_id, channel_id) / "channel_config.json" + return self._read_json(path) + + def write_channel_config(self, server_id: str, channel_id: str, config: Dict[str, Any]) -> None: + path = self._get_channel_path(server_id, channel_id) / "channel_config.json" + self._write_json(path, config) \ No newline at end of file diff --git a/decorators.py b/decorators.py new file mode 100644 index 0000000..5e4cf63 --- /dev/null +++ b/decorators.py @@ -0,0 +1,95 @@ +import time +import sqlite3 +from functools import wraps +from flask import g, jsonify, request, current_app + +rate_limit_store = {} +RATE_LIMIT_WINDOW = 3600 +RATE_LIMIT_MAX_ATTEMPTS = 10 + +SYSTEM_BADGES = { + "Beluga": "Dev", + "Admin": "Admin", + "TikyGaming": "Admin", + "idkk_cali": "Dev", + "ReBeluga": "Bot" +} + +def get_user_badge(username): + return SYSTEM_BADGES.get(username) + +def get_db(): + from database import get_db as database_get_db + return database_get_db(current_app) + +def rate_limit(key_func=None, max_attempts=None, window=None): + max_attempts = max_attempts or RATE_LIMIT_MAX_ATTEMPTS + window = window or RATE_LIMIT_WINDOW + + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + key = key_func() if key_func else request.remote_addr + now = time.time() + + if key not in rate_limit_store: + rate_limit_store[key] = [] + + rate_limit_store[key] = [t for t in rate_limit_store[key] if now - t < window] + + if len(rate_limit_store[key]) >= max_attempts: + return jsonify({'error': 'Too many requests. Please try again later.'}), 429 + + rate_limit_store[key].append(now) + return f(*args, **kwargs) + return decorated + return decorator + +def token_required(f): + @wraps(f) + def decorated(*args, **kwargs): + token = request.headers.get('Authorization') or request.cookies.get('auth_token') + if not token: + return jsonify({'error': 'Token is missing'}), 401 + + # Strip "Bearer " prefix if present + if token.startswith('Bearer '): + token = token[7:] + + db = get_db() + user = db.execute('SELECT * FROM users WHERE token = ?', (token,)).fetchone() + if not user: + return jsonify({'error': 'Invalid token'}), 401 + + g.user = dict(user) + if g.user.get('is_banned', 0) == 1: + return jsonify({'error': 'Account banned'}), 403 + return f(*args, **kwargs) + return decorated + +def role_required(min_role): + """ + Role hierarchy: Dev (1) > Admin (2) > None (0) + Higher roles (lower numbers) inherit permissions of lower roles. + """ + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + if not hasattr(g, 'user'): + return jsonify({'error': 'No user context'}), 401 + + username = g.user.get('username') + badge = get_user_badge(username) + + # 1 is highest, 2 is lower. + role_order = {'Dev': 1, 'Admin': 2} + user_level = role_order.get(badge, 99) # 99 for regular users + required_level = role_order.get(min_role, 99) + + # Dev (1) passes check for Admin (2) because 1 <= 2 + if user_level <= required_level: + return f(*args, **kwargs) + + return jsonify({'error': f'Insufficient permissions. Required: {min_role}'}), 403 + return decorated + return decorator \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..89e2570 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask==2.3.3 +Flask-CORS==4.0.0 +Flask-SocketIO==5.3.6 +Werkzeug==2.3.7 +Pillow>=10.0.0 +python-socketio==5.10.0 diff --git a/static/custom_502.html b/static/custom_502.html new file mode 100644 index 0000000..ef580cb --- /dev/null +++ b/static/custom_502.html @@ -0,0 +1,19 @@ + + + + + Технические работы + + + +
+

502 — Технические работы

+

Вы не волнуйтесь, это просто небольшие технические работы для загрузки нового кода на сервер.

+

Это обычно занимает от 5 до 10 минут.

+
+ + diff --git a/static/js/auth.js b/static/js/auth.js new file mode 100644 index 0000000..de03194 --- /dev/null +++ b/static/js/auth.js @@ -0,0 +1,183 @@ +// authentication related functions and event handling + +function showAuthScreen() { + authScreen.style.display = 'flex'; + chatScreen.style.display = 'none'; +} + +function showChatScreen() { + authScreen.style.display = 'none'; + chatScreen.style.display = 'flex'; + currentUserDisplay.textContent = `👤 ${username}`; + + loadServers(); + loadFriends(); + loadFriendRequests(); + loadUsers(); + // fetch profile to populate avatar and pronouns if needed + if (typeof loadProfile === 'function') loadProfile(); + startAutoRefresh(); + + // Request browser notification permission + requestNotificationPermission().then(granted => { + if (granted) { + console.log('Notification permission granted'); + } + }); + + // Connect to SocketIO for real-time updates + connectSocketIO(); +} + +async function register() { + const user = usernameInput.value.trim(); + const pass = passwordInput.value.trim(); + + if (!user || !pass) { + authMessage.textContent = 'Please fill in all fields'; + authMessage.className = 'auth-message error'; + return; + } + + const code = window.prompt('Enter the one-time code (e.g. ABC-DEF-GHI):'); + if (code === null || code.trim() === '') { + return; + } + + try { + const res = await fetch(`${API_URL}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: user, password: pass, invite_code: code.trim() }) + }); + + const text = await res.text(); + + if (!text || text.trim() === '') { + console.error('Empty response from server'); + authMessage.textContent = 'Server error: Empty response'; + authMessage.className = 'auth-message error'; + return; + } + + let data; + try { + data = JSON.parse(text); + } catch (parseError) { + console.error('Invalid JSON response:', text); + authMessage.textContent = 'Server error: Invalid response format'; + authMessage.className = 'auth-message error'; + return; + } + + if (res.ok) { + token = data.token; + userId = data.user_id; + username = data.username; + localStorage.setItem('token', token); + localStorage.setItem('userId', userId); + localStorage.setItem('username', username); + authMessage.textContent = 'Registration successful!'; + authMessage.className = 'auth-message success'; + setTimeout(showChatScreen, 500); + } else { + authMessage.textContent = data.error || 'Registration failed'; + authMessage.className = 'auth-message error'; + } + } catch (error) { + authMessage.textContent = 'Error: ' + error.message; + authMessage.className = 'auth-message error'; + } +} + +async function login() { + const user = usernameInput.value.trim(); + const pass = passwordInput.value.trim(); + + if (!user || !pass) { + authMessage.textContent = 'Please fill in all fields'; + authMessage.className = 'auth-message error'; + return; + } + + try { + const res = await fetch(`${API_URL}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: user, password: pass }) + }); + + // Get response text first to check if it's valid JSON + const text = await res.text(); + + // Check for empty response + if (!text || text.trim() === '') { + console.error('Empty response from server'); + authMessage.textContent = 'Server error: Empty response'; + authMessage.className = 'auth-message error'; + return; + } + + // Try to parse as JSON + let data; + try { + data = JSON.parse(text); + } catch (parseError) { + console.error('Invalid JSON response:', text); + authMessage.textContent = 'Server error: Invalid response format'; + authMessage.className = 'auth-message error'; + return; + } + + if (res.ok) { + token = data.token; + userId = data.user_id; + username = data.username; + localStorage.setItem('token', token); + localStorage.setItem('userId', userId); + localStorage.setItem('username', username); + authMessage.textContent = 'Login successful!'; + authMessage.className = 'auth-message success'; + setTimeout(showChatScreen, 500); + } else { + authMessage.textContent = data.error || 'Login failed'; + authMessage.className = 'auth-message error'; + } + } catch (error) { + authMessage.textContent = 'Error: ' + error.message; + authMessage.className = 'auth-message error'; + } +} + +function logout() { + localStorage.removeItem('token'); + localStorage.removeItem('userId'); + localStorage.removeItem('username'); + token = null; + userId = null; + username = null; + currentDMUserId = null; + messageInput.value = ''; + + // Disconnect SocketIO and stop auto-refresh to prevent memory leaks + disconnectSocketIO(); + stopAutoRefresh(); + + showAuthScreen(); +} + +// attach auth event listeners (called after vars are bound) +function initAuthListeners() { + loginBtn.addEventListener('click', login); + registerBtn.addEventListener('click', register); + logoutBtn.addEventListener('click', logout); +} + +// check token on load +function authInitCheck() { + if (token) { + showChatScreen(); + } else { + showAuthScreen(); + } +} diff --git a/static/js/chat.js b/static/js/chat.js new file mode 100644 index 0000000..d10fa34 --- /dev/null +++ b/static/js/chat.js @@ -0,0 +1,630 @@ +// ==================== +// Timezone utilities +// ==================== +function getUserTimezone() { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +function parseUTCToLocal(utcTimestamp) { + if (!utcTimestamp) return new Date(); + let isoString = utcTimestamp.replace(' ', 'T'); + if (!isoString.endsWith('Z') && !isoString.includes('+')) { + isoString += 'Z'; + } + return new Date(isoString); +} + +function formatLocalTime(utcTimestamp) { + const localDate = parseUTCToLocal(utcTimestamp); + const dateStr = localDate.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric' + }); + const timeStr = localDate.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit' + }); + return { dateStr, timeStr }; +} + +// ==================== +// Messaging and chat related logic +// ==================== +let socket = null; +let isSending = false; + +function connectSocketIO() { + if (!token) return; + if (socket) { + socket.disconnect(); + } + console.log('Connecting to SocketIO...'); + try { + // Connect to the same host/port as the HTTP requests + const socketUrl = window.location.origin; + socket = io(socketUrl, { + transports: ['websocket', 'polling'] + }); + + socket.on('connect', () => { + console.log('SocketIO connected'); + }); + + socket.on('new_message', (message) => { + console.log('SocketIO: New message received:', message); + handleSocketMessage(message); + }); + + socket.on('user_ban_status', (data) => { + console.log('SocketIO: User ban status:', data); + if (data.user_id == userId && data.is_banned) { + // Current user was banned, disconnect + alert('You have been banned from the chat.'); + logout(); + } + }); + + socket.on('delete_message', (data) => { + console.log('SocketIO: Delete message:', data); + // Remove the message from the UI + const messageElement = document.querySelector(`[data-msg-id="${data.id}"]`); + if (messageElement) { + messageElement.remove(); + } + }); + + socket.on('system_notification', (data) => { + console.log('SocketIO: System notification:', data); + // Show system notification + showBrowserNotification('System Notification', data.message, null, null); + // Could also display in chat if needed + }); + + socket.on('disconnect', () => { + console.log('SocketIO disconnected'); + }); + + socket.on('connect_error', (error) => { + console.error('SocketIO connection error:', error); + // Try to reconnect after a delay + setTimeout(() => { + if (!socket.connected) { + console.log('Retrying SocketIO connection...'); + connectSocketIO(); + } + }, 5000); + }); + } catch (e) { + console.error('SocketIO: Error creating connection:', e); + } +} + +function handleSocketMessage(message) { + if (message.sender_id == userId) return; + let key = 'global'; + if (message.channel_id) { + key = conversationKey('c', message.channel_id); + } else if (message.is_global != 1) { + const otherUserId = message.sender_id == userId ? message.receiver_id : message.sender_id; + key = conversationKey('d', otherUserId); + } + if (shouldNotifyMessage(message, key)) { + let title = ''; + let body = ''; + if (message.channel_id) { + let channelName = 'Channel'; + let serverName = 'Server'; + servers.forEach(s => { + const ch = s.channels.find(c => c.id == message.channel_id); + if (ch) { + channelName = ch.name; + serverName = s.name; + } + }); + title = `${serverName} - #${channelName}`; + } else if (message.is_global == 1) { + title = 'Global Chat'; + } else { + title = `DM from ${message.username}`; + } + let contentPreview = ''; + try { + const parsed = JSON.parse(message.content); + contentPreview = parsed.text || parsed.file?.filename || '[File]'; + } catch (e) { + contentPreview = message.content; + } + body = contentPreview.substring(0, 100); + if (contentPreview.length > 100) body += '...'; + showBrowserNotification(title, body, message.avatar_url || null, null); + markMessageNotified(message.id); + } + const ts = new Date(message.timestamp).getTime(); + if (!lastSeen[key] || ts > lastSeen[key]) { + unreadCounts[key] = (unreadCounts[key] || 0) + 1; + lastSeen[key] = ts; + renderUnreadIndicators(); + } + + // Add the message to the UI if it's for the current view + //console.log('Attempting to display message:', message, 'currentChannelId:', currentChannelId, 'currentDMUserId:', currentDMUserId); + displayMessages([message]); +} + +function disconnectSocketIO() { + if (socket) { + socket.disconnect(); + socket = null; + } +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +async function requestNotificationPermission() { + if (!('Notification' in window)) { + console.log('This browser does not support notifications'); + return false; + } + if (Notification.permission === 'granted') { + return true; + } + if (Notification.permission !== 'denied') { + const permission = await Notification.requestPermission(); + notificationPermission = permission; + return permission === 'granted'; + } + return false; +} + +function showBrowserNotification(title, body, icon = null, onClick = null) { + if (notificationPermission !== 'granted') { + return; + } + const options = { + body: body, + icon: icon || '/static/avatars/avatar_1.png', + badge: '/static/avatars/avatar_1.png', + tag: title, + requireInteraction: false + }; + const notification = new Notification(title, options); + if (onClick) { + notification.onclick = function() { + window.focus(); + onClick(); + notification.close(); + }; + } + setTimeout(() => { + notification.close(); + }, 5000); +} + +function shouldNotifyMessage(msg, conversationKey) { + if (msg.sender_id == userId) return false; + const isCurrentConversation = ( + (currentDMUserId && conversationKey === `d${currentDMUserId}`) || + (currentChannelId && conversationKey === `c${currentChannelId}`) || + (!currentDMUserId && !currentChannelId && conversationKey === 'global') + ); + if (isCurrentConversation && document.visibilityState === 'visible') { + return false; + } + const msgKey = `msg_${msg.id}`; + if (lastNotifiedMessages[msgKey]) { + return false; + } + return true; +} + +function markMessageNotified(msgId) { + lastNotifiedMessages[`msg_${msgId}`] = Date.now(); + const keys = Object.keys(lastNotifiedMessages); + if (keys.length > 100) { + const cutoff = Date.now() - (24 * 60 * 60 * 1000); + keys.forEach(key => { + if (lastNotifiedMessages[key] < cutoff) { + delete lastNotifiedMessages[key]; + } + }); + } +} + +function renderMessageContent(content) { + let parsed; + try { + parsed = JSON.parse(content); + } catch (e) { + parsed = null; + } + if (parsed && parsed.file) { + const file = parsed.file; + let html = ''; + if (parsed.text) { + html += escapeHtml(parsed.text) + '
'; + } + const url = file.url; + const mime = file.mimetype || ''; + if (mime.startsWith('image/')) { + html += ``; + } else if (mime.startsWith('video/')) { + html += ``; + } else if (mime.startsWith('audio/')) { + html += ``; + } else { + html += `${escapeHtml(file.filename)} (${formatBytes(file.size)})`; + } + return html; + } + return escapeHtml(content); +} + +async function sendMessage() { + if (isSending) return; + + const text = messageInput.value.trim(); + const file = fileInput ? fileInput.files[0] : null; + if (!text && !file) return; + + isSending = true; + sendBtn.disabled = true; + sendBtn.classList.add('btn-disabled'); + messageInput.readOnly = true; + messageInput.classList.add('input-disabled'); + try { + let res; + if (file) { + const form = new FormData(); + if (text) form.append('content', text); + form.append('file', file); + if (currentDMUserId) form.append('receiver_id', currentDMUserId); + if (currentChannelId) form.append('channel_id', currentChannelId); + if (replyToMessage) form.append('reply_to', replyToMessage.id); + res = await fetch(`${API_URL}/messages`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: form + }); + } else { + const body = { content: text }; + if (currentDMUserId) body.receiver_id = currentDMUserId; + if (currentChannelId) body.channel_id = currentChannelId; + if (replyToMessage) body.reply_to = replyToMessage.id; + res = await fetch(`${API_URL}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(body) + }); + } + if (res && res.ok) { + messageInput.value = ''; + fileInput.value = ''; + filePreview.style.display = 'none'; + cancelReply(); + if (currentDMUserId) { + loadDM(currentDMUserId); + } else { + loadChannel(currentChannelId); + } + } else if (res) { + const err = await res.json(); + console.error('sendMessage error response', err); + } + } catch (error) { + console.error('Error sending message:', error); + } finally { + isSending = false; + sendBtn.disabled = false; + sendBtn.classList.remove('btn-disabled'); + messageInput.readOnly = false; + messageInput.classList.remove('input-disabled'); + } +} + +async function loadChannel(channelId) { + try { + let url = `${API_URL}/messages?channel_id=${channelId}`; + if (lastFetchedTs) url += `&since=${encodeURIComponent(lastFetchedTs)}`; + const res = await fetch(url, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const messages = await res.json(); + displayMessages(messages, !!lastFetchedTs); + } + } catch (error) { + console.error('Error loading channel messages:', error); + } +} + +async function loadGlobalChat() { + if (currentChannelId) return loadChannel(currentChannelId); + try { + const res = await fetch(`${API_URL}/messages`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const messages = await res.json(); + displayMessages(messages); + } + } catch (error) { + console.error('Error loading messages:', error); + } +} + +async function loadUsers() { + try { + const res = await fetch(`${API_URL}/users`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + allUsers = await res.json(); + } + } catch (error) { + console.error('Error loading users:', error); + } +} + +async function loadDM(userId) { + try { + const res = await fetch(`${API_URL}/dm/${userId}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const messages = await res.json(); + displayMessages(messages); + } + } catch (error) { + console.error('Error loading DM:', error); + } +} + +let lastFetchedTs = null; +let isAtBottom = true; + +function displayMessages(messages, append = false) { + const existing = new Set(); + [...messagesContainer.querySelectorAll('.message')].forEach(el => { + const id = el.getAttribute('data-msg-id'); + if (id) existing.add(String(id)); + }); + + messages.forEach(msg => { + //console.log('Processing message:', msg.id, 'channel_id:', msg.channel_id, 'currentChannelId:', currentChannelId, 'currentDMUserId:', currentDMUserId, 'is_global:', msg.is_global); + + if (typeof currentChannelId !== 'undefined' && currentChannelId) { + if (String(msg.channel_id || '') !== String(currentChannelId)) { + console.log('Message filtered out: channel mismatch'); + return; + } + } else if (typeof currentDMUserId !== 'undefined' && currentDMUserId) { + const rid = msg.receiver_id || msg.receiver || null; + if (!(String(msg.sender_id) === String(currentDMUserId) || String(rid) === String(currentDMUserId))) return; + } else { + if (msg.is_global === 0 || msg.is_global === '0') return; + } + + const msgId = String(msg.id || msg._id || msg.timestamp); + if (existing.has(msgId)) { + return; + } + + const msgEl = document.createElement('div'); + msgEl.className = `message ${msg.sender_id == userId ? 'own' : ''}`; + msgEl.setAttribute('data-msg-id', msgId); + msgEl.setAttribute('data-sender-id', msg.sender_id); + + let avatarHtml = ''; + if (msg.avatar_url) { + avatarHtml = `avatar`; + } + + let badgeHtml = ''; + if (msg.badge) { + const badgeClass = msg.badge.toLowerCase().replace(/\s+/g, '-'); + badgeHtml = `${msg.badge}`; + } + + let pron = msg.pronouns ? ` (${msg.pronouns})` : ''; + + const { dateStr, timeStr } = formatLocalTime(msg.timestamp); + const renderedContent = renderMessageContent(msg.content); + + let replyIndicatorHtml = ''; + if (msg.reply_to) { + replyIndicatorHtml = `
Replying to message #${msg.reply_to}
`; + } + + // FIXED: Proper Telegram-style message structure + msgEl.innerHTML = ` +
+ +
+ ${avatarHtml} +
+
+ ${msg.username}${badgeHtml}${pron} + ${dateStr} ${timeStr} +
+
+ ${replyIndicatorHtml} +
${renderedContent}
+
+
+ `; + messagesContainer.appendChild(msgEl); + //console.log('Message added to DOM:', msgId); + }); + + if (isAtBottom) { + requestAnimationFrame(() => { + messagesContainer.scrollTop = messagesContainer.scrollHeight; + }); + } + + if (messages.length) { + const lastTs = new Date(messages[messages.length - 1].timestamp).toISOString(); + lastFetchedTs = lastTs; + let key; + if (currentDMUserId) { + key = conversationKey('d', currentDMUserId); + } else if (currentChannelId) { + key = conversationKey('c', currentChannelId); + } else { + key = 'global'; + } + lastSeen[key] = new Date(lastTs).getTime(); + unreadCounts[key] = 0; + renderUnreadIndicators(); + } +} + +let autoRefreshInterval = null; + +function startAutoRefresh() { + if (autoRefreshInterval) { + clearInterval(autoRefreshInterval); + } + autoRefreshInterval = setInterval(async () => { + const now = Date.now(); + if (!window.lastFriendRefresh || now - window.lastFriendRefresh > 10000) { + await loadFriendRequests(); + await loadFriends(); + window.lastFriendRefresh = now; + } + }, 5000); +} + +function stopAutoRefresh() { + if (autoRefreshInterval) { + clearInterval(autoRefreshInterval); + autoRefreshInterval = null; + } + window.lastFriendRefresh = null; +} + + + +function renderUnreadIndicators() { + servers.forEach(s => { + s.channels.forEach(ch => { + const key = conversationKey('c', ch.id); + const count = unreadCounts[key]; + const el = [...document.querySelectorAll('.channel')].find(n => n.textContent.includes(ch.name)); + if (el) { + let badge = el.querySelector('.unread-badge'); + if (count) { + if (!badge) { + badge = document.createElement('span'); + badge.className = 'unread-badge'; + el.appendChild(badge); + } + badge.textContent = count; + } else if (badge) { + badge.remove(); + } + } + }); + }); + displayDMList(); +} + +function initChatListeners() { + sendBtn.addEventListener('click', () => { + if (isSending) return; + sendMessage(); + }); + messageInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + if (isSending) return; + e.preventDefault(); + sendMessage(); + } + }); + attachBtn.addEventListener('click', () => { + fileInput.click(); + }); + fileInput.addEventListener('change', () => { + if (fileInput.files && fileInput.files[0]) { + const file = fileInput.files[0]; + const name = file.name; + const size = formatBytes(file.size); + filePreviewText.textContent = `📎 ${name} (${size})`; + filePreview.style.display = 'flex'; + } + }); + filePreviewClear.addEventListener('click', () => { + fileInput.value = ''; + filePreview.style.display = 'none'; + }); + messagesContainer.addEventListener('scroll', () => { + const { scrollTop, scrollHeight, clientHeight } = messagesContainer; + isAtBottom = scrollHeight - scrollTop - clientHeight < 50; + }); + messagesContainer.addEventListener('click', (e) => { + const replyBtn = e.target.closest('.msg-reply-btn'); + if (replyBtn) { + const msgId = replyBtn.dataset.msgId; + const username = replyBtn.dataset.username; + const messageEl = replyBtn.closest('.message'); + const contentEl = messageEl.querySelector('.message-content'); + let content = contentEl ? contentEl.textContent : ''; + if (content.length > 50) { + content = content.substring(0, 50) + '...'; + } + startReply(msgId, username, content); + return; + } + const replyIndicator = e.target.closest('.reply-indicator'); + if (replyIndicator) { + const replyToId = replyIndicator.dataset.replyTo; + if (replyToId) { + scrollToMessage(replyToId); + } + } + }); + replyCancelBtn.addEventListener('click', cancelReply); +} + +function startReply(msgId, username, content) { + replyToMessage = { id: msgId, username: username, content: content }; + replyToUsername = username; + replyUsername.textContent = username; + replyPreviewText.textContent = content; + replyPreview.style.display = 'flex'; + messageInput.focus(); +} + +function cancelReply() { + replyToMessage = null; + replyToUsername = null; + replyPreview.style.display = 'none'; + replyUsername.textContent = ''; + replyPreviewText.textContent = ''; +} + +function scrollToMessage(msgId) { + const targetMsg = messagesContainer.querySelector(`[data-msg-id="${msgId}"]`); + if (targetMsg) { + targetMsg.scrollIntoView({ behavior: 'smooth', block: 'center' }); + targetMsg.style.transition = 'background-color 0.3s ease'; + const originalBg = targetMsg.style.backgroundColor; + targetMsg.style.backgroundColor = 'rgba(82, 136, 193, 0.3)'; + setTimeout(() => { + targetMsg.style.backgroundColor = originalBg; + }, 1500); + } else { + console.log('Message not found:', msgId); + } +} \ No newline at end of file diff --git a/static/js/friends.js b/static/js/friends.js new file mode 100644 index 0000000..3efb77a --- /dev/null +++ b/static/js/friends.js @@ -0,0 +1,205 @@ +// friends, DM list, and friend request logic + +async function loadFriends() { + try { + const res = await fetch(`${API_URL}/friends`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (res.ok) { + friends = await res.json(); + // seed lastSeen for each DM + friends.forEach(f => { + const key = conversationKey('d', f.id); + if (!lastSeen[key]) lastSeen[key] = Date.now(); + }); + displayFriends(); + displayDMList(); + + // Update Service Worker with friends list for background notifications + updateServiceWorkerFriends(); + } + } catch (error) { + console.error('Error loading friends:', error); + } +} + +async function loadFriendRequests() { + try { + const res = await fetch(`${API_URL}/friend-requests`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (res.ok) { + const requests = await res.json(); + displayFriendRequests(requests); + } + } catch (error) { + console.error('Error loading friend requests:', error); + } +} + +function displayFriends() { + friendsList.innerHTML = ''; + if (friends.length === 0) { + friendsList.innerHTML = '
No friends yet. Add some!
'; + return; + } + friends.forEach(friend => { + const friendEl = document.createElement('div'); + friendEl.className = 'friend-item'; + let av = friend.avatar_url ? `av` : ''; + + // Generate badge HTML if user has a system badge + let badgeHtml = ''; + if (friend.badge) { + const badgeClass = friend.badge.toLowerCase().replace(/\s+/g, '-'); + badgeHtml = `${friend.badge}`; + } + + friendEl.innerHTML = `${av}@${friend.username}${badgeHtml}`; + friendEl.onclick = () => selectDM(friend.id, friend.username); + friendsList.appendChild(friendEl); + }); +} + +function displayDMList() { + dmList.innerHTML = ''; + if (friends.length === 0) { + dmList.innerHTML = '
No friends
'; + return; + } + friends.forEach(friend => { + const userEl = document.createElement('div'); + userEl.className = 'dm-user'; + userEl.setAttribute('data-user-id', friend.id); + let av = friend.avatar_url ? `av` : ''; + + // Generate badge HTML if user has a system badge + let badgeHtml = ''; + if (friend.badge) { + const badgeClass = friend.badge.toLowerCase().replace(/\s+/g, '-'); + badgeHtml = `${friend.badge}`; + } + + userEl.innerHTML = `${av}@${friend.username}${badgeHtml}`; + const count = unreadCounts[conversationKey('d', friend.id)]; + if (count) { + const badge = document.createElement('span'); + badge.className = 'unread-badge'; + badge.textContent = count; + userEl.appendChild(badge); + } + userEl.onclick = () => selectDM(friend.id, friend.username); + dmList.appendChild(userEl); + }); +} + +function displayFriendRequests(requests) { + friendRequestsList.innerHTML = ''; + if (requests.length === 0) { + friendRequestsList.innerHTML = '
No pending requests
'; + return; + } + requests.forEach(req => { + const reqEl = document.createElement('div'); + reqEl.className = 'friend-request-item'; + reqEl.innerHTML = ` + @${req.username} +
+ + +
+ `; + friendRequestsList.appendChild(reqEl); + }); +} + +function displayAddFriendModal() { + modalError.textContent = ''; + modalError.className = 'modal-error'; + friendUsernameInput.value = ''; + friendUsernameInput.focus(); +} + +async function sendFriendRequest(username) { + if (!username || !username.trim()) { + modalError.textContent = 'Please enter a username'; + modalError.className = 'modal-error show'; + return; + } + + try { + const res = await fetch(`${API_URL}/friend-request`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ username: username.trim() }) + }); + + const data = await res.json(); + + if (res.ok) { + modalError.textContent = 'Friend request sent!'; + modalError.className = 'modal-error show success'; + friendUsernameInput.value = ''; + setTimeout(() => { + addFriendModal.style.display = 'none'; + loadFriendRequests(); + }, 1000); + } else { + modalError.textContent = data.error || 'Error sending request'; + modalError.className = 'modal-error show'; + } + } catch (error) { + modalError.textContent = 'Error: ' + error.message; + modalError.className = 'modal-error show'; + } +} + +async function respondFriendRequest(requestId, action) { + try { + const res = await fetch(`${API_URL}/friend-request/${requestId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ action: action }) + }); + + if (res.ok) { + loadFriends(); + loadFriendRequests(); + } + } catch (error) { + console.error('Error responding to friend request:', error); + } +} + +// event hooks for friend UI +function initFriendListeners() { + addFriendBtn.addEventListener('click', () => { + displayAddFriendModal(); + addFriendModal.style.display = 'flex'; + }); + closeModal.addEventListener('click', () => { + addFriendModal.style.display = 'none'; + }); + addFriendModal.addEventListener('click', (e) => { + if (e.target === addFriendModal) { + addFriendModal.style.display = 'none'; + } + }); + modalAddBtn.addEventListener('click', () => { + sendFriendRequest(friendUsernameInput.value); + }); + friendUsernameInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + sendFriendRequest(friendUsernameInput.value); + } + }); +} diff --git a/static/js/profile.js b/static/js/profile.js new file mode 100644 index 0000000..90d81e2 --- /dev/null +++ b/static/js/profile.js @@ -0,0 +1,360 @@ +// profile customization logic +async function loadProfile() { + try { + const res = await fetch(`${API_URL}/profile`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const user = await res.json(); + // populate form fields + profileAvatarInput.value = ''; + profileDescInput.value = user.description || ''; + profilePronounsInput.value = user.pronouns || ''; + // show preview if avatar exists + setAvatarPreview(user.avatar_url); + // also update header avatar if present + updateHeaderAvatar(user.avatar_url); + } + } catch (e) { + console.error('error loading profile', e); + } +} + +function displayProfileModal() { + profileModal.style.display = 'flex'; + loadProfile(); +} + +async function saveProfile() { + const form = new FormData(); + const file = profileAvatarInput.files && profileAvatarInput.files[0]; + if (file) { + form.append('avatar_file', file); + } + form.append('description', profileDescInput.value.trim()); + form.append('pronouns', profilePronounsInput.value.trim()); + + try { + const res = await fetch(`${API_URL}/profile`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: form + }); + if (res.ok) { + const updated = await res.json(); + updateHeaderAvatar(updated.avatar_url); + profileModal.style.display = 'none'; + } + } catch (e) { + console.error('error saving profile', e); + } +} + +function updateHeaderAvatar(url) { + if (url) { + let img = document.querySelector('.header-avatar'); + if (!img) { + img = document.createElement('img'); + img.className = 'header-avatar'; + if (currentUserDisplay) { + currentUserDisplay.prepend(img); + } + } + img.src = url; + img.alt = 'avatar'; + img.style.display = 'block'; + img.onerror = function() { + this.style.display = 'none'; + }; + } +} + +// profile modal preview +function setAvatarPreview(url) { + let prev = document.getElementById('avatar-preview'); + if (!prev) { + prev = document.createElement('img'); + prev.id = 'avatar-preview'; + prev.style.maxWidth = '100px'; + prev.style.maxHeight = '100px'; + prev.style.borderRadius = '50%'; + prev.style.objectFit = 'cover'; + if (profileAvatarInput && profileAvatarInput.parentNode) { + profileAvatarInput.parentNode.insertBefore(prev, profileAvatarInput.nextSibling); + } + } + + if (url) { + prev.src = url; + prev.style.display = 'block'; + prev.onerror = function() { + this.style.display = 'none'; + }; + } else { + prev.style.display = 'none'; + } +} + +// View own profile +async function viewOwnProfile() { + if (!userProfileModal) return; + currentViewingUserId = userId; // Set to current user + + try { + const res = await fetch(`${API_URL}/profile`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (res.ok) { + const user = await res.json(); + + // Set user info + userProfileUsername.textContent = user.username; + + if (user.avatar_url && user.avatar_url.trim() !== '') { + userProfileAvatar.src = user.avatar_url; + userProfileAvatar.style.display = 'block'; + } else { + userProfileAvatar.style.display = 'none'; + } + + if (user.pronouns && user.pronouns.trim() !== '') { + userProfilePronouns.textContent = user.pronouns; + userProfilePronouns.style.display = 'block'; + } else { + userProfilePronouns.style.display = 'none'; + } + + if (user.description && user.description.trim() !== '') { + userProfileDescription.textContent = user.description; + } else { + userProfileDescription.textContent = ''; + } + + // Show "Edit Profile" button instead of "Add Friend" + userProfileAddFriendBtn.textContent = 'Edit Profile'; + userProfileAddFriendBtn.classList.remove('added'); + userProfileAddFriendBtn.disabled = false; + + // Remove old event listeners and add new one + const newBtn = userProfileAddFriendBtn.cloneNode(true); + userProfileAddFriendBtn.parentNode.replaceChild(newBtn, userProfileAddFriendBtn); + userProfileAddFriendBtn = newBtn; + + // Add click handler for edit profile + userProfileAddFriendBtn.addEventListener('click', () => { + userProfileModal.style.display = 'none'; + displayProfileModal(); + }); + + userProfileModal.style.display = 'flex'; + } + } catch (error) { + console.error('Error loading own profile:', error); + } +} + +// User profile preview modal functionality +let currentViewingUserId = null; + +async function showUserProfile(userId) { + if (!userProfileModal) return; + currentViewingUserId = userId; + + try { + const res = await fetch(`${API_URL}/users/${userId}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (res.ok) { + const user = await res.json(); + + // Set user info + userProfileUsername.textContent = user.username; + + // Handle avatar - check if URL exists and is not empty + if (user.avatar_url && user.avatar_url.trim() !== '') { + userProfileAvatar.src = user.avatar_url; + userProfileAvatar.style.display = 'block'; + userProfileAvatar.onerror = function() { + // Fallback if image fails to load + this.style.display = 'none'; + }; + } else { + // Hide avatar if no URL + userProfileAvatar.style.display = 'none'; + } + + // Handle pronouns + if (user.pronouns && user.pronouns.trim() !== '') { + userProfilePronouns.textContent = user.pronouns; + userProfilePronouns.style.display = 'block'; + } else { + userProfilePronouns.style.display = 'none'; + } + + // Handle description + if (user.description && user.description.trim() !== '') { + userProfileDescription.textContent = user.description; + } else { + userProfileDescription.textContent = ''; + } + + // Check if already friends + const isFriend = friends.some(f => f.id === user.id); + const isSelf = String(user.id) === String(userId); + + if (isFriend) { + userProfileAddFriendBtn.textContent = 'Friends'; + userProfileAddFriendBtn.classList.add('added'); + userProfileAddFriendBtn.disabled = true; + } else if (isSelf) { + userProfileAddFriendBtn.textContent = 'Your Profile'; + userProfileAddFriendBtn.classList.add('added'); + userProfileAddFriendBtn.disabled = true; + } else { + userProfileAddFriendBtn.textContent = 'Add Friend'; + userProfileAddFriendBtn.classList.remove('added'); + userProfileAddFriendBtn.disabled = false; + } + + userProfileModal.style.display = 'flex'; + } + } catch (error) { + console.error('Error loading user profile:', error); + } +} + +async function addFriendFromProfile() { + if (!currentViewingUserId || userProfileAddFriendBtn.disabled) return; + + // Get username from the display + const username = userProfileUsername.textContent; + + try { + const res = await fetch(`${API_URL}/friend-request`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ username: username }) + }); + + if (res.ok) { + userProfileAddFriendBtn.textContent = 'Request Sent'; + userProfileAddFriendBtn.classList.add('added'); + userProfileAddFriendBtn.disabled = true; + } else { + const data = await res.json(); + alert(data.error || 'Error sending friend request'); + } + } catch (error) { + console.error('Error adding friend:', error); + } +} + +function initUserProfileListeners() { + // Close button + if (userProfileCloseBtn) { + userProfileCloseBtn.addEventListener('click', () => { + if (userProfileModal) userProfileModal.style.display = 'none'; + }); + } + + // Close on background click + if (userProfileModal) { + userProfileModal.addEventListener('click', (e) => { + if (e.target === userProfileModal) { + userProfileModal.style.display = 'none'; + } + }); + } + + // Add friend button + if (userProfileAddFriendBtn) { + userProfileAddFriendBtn.addEventListener('click', addFriendFromProfile); + } +} + +// Make message usernames clickable to view profile +function initMessageUsernameClickHandlers() { + // This will be called after messages are rendered + document.addEventListener('click', (e) => { + const authorSpan = e.target.closest('.message-author'); + if (authorSpan && !authorSpan.classList.contains('own-author')) { + const messageEl = authorSpan.closest('.message'); + if (messageEl) { + const senderId = messageEl.getAttribute('data-sender-id'); + if (senderId && String(senderId) !== String(userId)) { + showUserProfile(senderId); + } + } + } + }); +} + +function initProfileListeners() { + // Initialize user profile modal listeners + initUserProfileListeners(); + initMessageUsernameClickHandlers(); + + // View own profile button + const viewProfileBtn = document.getElementById('view-profile-btn'); + if (viewProfileBtn) { + try { + viewProfileBtn.addEventListener('click', viewOwnProfile); + } catch(e) { + console.error('viewProfileBtn attach', e); + } + } else if (profileBtn) { + // Fallback to old button + try { + profileBtn.addEventListener('click', viewOwnProfile); + } catch(e) { + console.error('profileBtn attach', e); + } + } + + if (profileCloseBtn) { + try { + profileCloseBtn.addEventListener('click', () => { + profileModal.style.display = 'none'; + }); + } catch(e) { + console.error('profileCloseBtn attach', e); + } + } + + if (profileModal) { + try { + profileModal.addEventListener('click', (e) => { + if (e.target === profileModal) profileModal.style.display = 'none'; + }); + } catch(e) { + console.error('profileModal attach', e); + } + } + + if (profileSaveBtn) { + try { + profileSaveBtn.addEventListener('click', saveProfile); + } catch(e) { + console.error('profileSaveBtn attach', e); + } + } + + // watch file input changes for preview + if (profileAvatarInput) { + profileAvatarInput.addEventListener('change', () => { + const file = profileAvatarInput.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = e => setAvatarPreview(e.target.result); + reader.readAsDataURL(file); + } + }); + } +} \ No newline at end of file diff --git a/static/js/servers.js b/static/js/servers.js new file mode 100644 index 0000000..bb77cd2 --- /dev/null +++ b/static/js/servers.js @@ -0,0 +1,145 @@ +// server and channel management + +async function loadServers() { + try { + const res = await fetch(`${API_URL}/servers`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + servers = await res.json(); + // seed lastSeen timestamps so we don't mark existing messages as unread + servers.forEach(s => { + s.channels.forEach(ch => { + const key = conversationKey('c', ch.id); + if (!lastSeen[key]) lastSeen[key] = Date.now(); + }); + }); + renderServers(); + if (servers.length > 0) { + selectServer(servers[0].id); + } + + // Update Service Worker with channels list for background notifications + updateServiceWorkerChannels(); + } + } catch (error) { + console.error('Error loading servers:', error); + } +} + +function renderServers() { + const bar = document.getElementById('servers-bar'); + bar.querySelectorAll('.server-icon').forEach(n => n.remove()); + servers.forEach(s => { + const icon = document.createElement('div'); + icon.className = 'server-icon'; + icon.textContent = s.name.charAt(0).toUpperCase(); + icon.onclick = () => selectServer(s.id); + bar.insertBefore(icon, document.getElementById('create-server-btn')); + }); +} + +function addChannelButton() { + const list = document.getElementById('channels-list'); + let btn = document.getElementById('create-channel-btn'); + if (!btn) { + btn = document.createElement('div'); + btn.id = 'create-channel-btn'; + btn.className = 'channel create-channel-btn'; + btn.textContent = '+ create channel'; + btn.onclick = async () => { + const name = prompt('Channel name:'); + if (!name) return; + const res = await fetch(`${API_URL}/servers/${currentServerId}/channels`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ name }) + }); + if (res.ok) { + const newch = await res.json(); + const srv = servers.find(s=>s.id===currentServerId); + srv.channels.push(newch); + selectServer(currentServerId); + } else { + const err = await res.json(); + alert(err.error || 'failed'); + } + }; + list.prepend(btn); + } +} + +function selectServer(id) { + currentServerId = id; + const srv = servers.find(s => s.id === id); + document.querySelector('.server-name').textContent = srv.name; + const list = document.getElementById('channels-list'); + list.innerHTML = ''; + addChannelButton(); + srv.channels.forEach(ch => { + const el = document.createElement('div'); + el.className = 'channel'; + el.textContent = '# ' + ch.name; + // badge + const count = unreadCounts[conversationKey('c', ch.id)]; + if (count) { + const badge = document.createElement('span'); + badge.className = 'unread-badge'; + badge.textContent = count; + el.appendChild(badge); + } + el.onclick = () => selectChannel(ch.id, ch.name); + list.appendChild(el); + }); + if (srv.channels.length>0) selectChannel(srv.channels[0].id, srv.channels[0].name); + messageInput.value = ''; +} + +function selectChannel(id, name) { + currentChannelId = id; + headerTitle.textContent = `#${name}`; + // clear current messages when switching channels to avoid mixed history + if (messagesContainer) messagesContainer.innerHTML = ''; + lastFetchedTs = null; + isAtBottom = true; // reset scroll state for new conversation + currentDMUserId = null; + loadChannel(id); + document.querySelectorAll('.channel').forEach(ch => ch.classList.remove('active')); + const el = [...document.querySelectorAll('.channel')].find(n=>n.textContent.includes(name)); + if(el) el.classList.add('active'); + messageInput.value = ''; +} + +function selectGlobalChat() { + currentDMUserId = null; + headerTitle.textContent = 'Глобальный чат'; + if (messagesContainer) messagesContainer.innerHTML = ''; + lastFetchedTs = null; + isAtBottom = true; // reset scroll state for new conversation + currentChannelId = null; + loadGlobalChat(); + document.querySelectorAll('.channel').forEach(ch => ch.classList.remove('active')); + document.querySelector('[data-channel="global"]').classList.add('active'); + messageInput.value = ''; +} + +function initServerListeners() { + const btn = document.getElementById('create-server-btn'); + if (btn) { + btn.addEventListener('click', async () => { + const name = prompt('Server name:'); + if (!name) return; + const res = await fetch(`${API_URL}/servers`, { + method: 'POST', + headers: { 'Content-Type':'application/json', 'Authorization':`Bearer ${token}` }, + body: JSON.stringify({name}) + }); + if (res.ok) { + loadServers(); + } else { + const err = await res.json(); + alert(err.error || 'failed'); + } + }); + } +} diff --git a/static/js/ui.js b/static/js/ui.js new file mode 100644 index 0000000..009f13a --- /dev/null +++ b/static/js/ui.js @@ -0,0 +1,131 @@ +// general UI initialization, element bindings, and tab handling +document.addEventListener('DOMContentLoaded', () => { +// bind DOM elements to globals +authScreen = document.getElementById('auth-screen'); +chatScreen = document.getElementById('chat-screen'); +usernameInput = document.getElementById('username-input'); +passwordInput = document.getElementById('password-input'); +loginBtn = document.getElementById('login-btn'); +registerBtn = document.getElementById('register-btn'); +logoutBtn = document.getElementById('logout-btn'); +messagesContainer = document.getElementById('messages-container'); +messageInput = document.getElementById('message-input'); +sendBtn = document.getElementById('send-btn'); +fileInput = document.getElementById('file-input'); +attachBtn = document.getElementById('attach-btn'); +filePreview = document.getElementById('file-preview'); +filePreviewText = document.getElementById('file-preview-text'); +filePreviewClear = document.getElementById('file-preview-clear'); +authMessage = document.getElementById('auth-message'); +currentUserDisplay = document.getElementById('current-user'); +headerTitle = document.getElementById('header-title'); +dmList = document.getElementById('dm-list'); +friendsList = document.getElementById('friends-list'); +friendRequestsList = document.getElementById('friend-requests-list'); +addFriendBtn = document.getElementById('add-friend-btn'); +addFriendModal = document.getElementById('add-friend-modal'); +closeModal = document.querySelector('.close-modal'); +friendUsernameInput = document.getElementById('friend-username-input'); +modalAddBtn = document.getElementById('modal-add-btn'); +modalError = document.getElementById('modal-error'); +tabBtns = document.querySelectorAll('.tab-btn'); +chatTab = document.getElementById('chat-tab'); +friendsTab = document.getElementById('friends-tab'); +profileBtn = document.getElementById('profile-btn'); +profileModal = document.getElementById('profile-modal'); +profileAvatarInput = document.getElementById('profile-avatar-input'); +profileDescInput = document.getElementById('profile-desc-input'); +profilePronounsInput = document.getElementById('profile-pronouns-input'); +profileSaveBtn = document.getElementById('profile-save-btn'); +profileCloseBtn = document.querySelector('.profile-close'); +// User profile modal elements +userProfileModal = document.getElementById('user-profile-modal'); +userProfileAvatar = document.getElementById('user-profile-avatar'); +userProfileUsername = document.getElementById('user-profile-username'); +userProfilePronouns = document.getElementById('user-profile-pronouns'); +userProfileDescription = document.getElementById('user-profile-description'); +userProfileAddFriendBtn = document.getElementById('user-profile-add-friend-btn'); +userProfileCloseBtn = document.querySelector('.user-profile-close'); + +// Reply preview elements +replyPreview = document.getElementById('reply-preview'); +replyUsername = document.getElementById('reply-username'); +replyPreviewText = document.getElementById('reply-preview-text'); +replyCancelBtn = document.getElementById('reply-cancel-btn'); + +// Mobile sidebar elements +sidebarToggle = document.getElementById('sidebar-toggle'); +sidebarOverlay = document.getElementById('sidebar-overlay'); +sidebar = document.querySelector('.sidebar'); + +// set up listeners from various modules +initAuthListeners(); +initChatListeners(); +initFriendListeners(); +initServerListeners(); +initProfileListeners(); + +// Mobile sidebar toggle +if (sidebarToggle) { + sidebarToggle.addEventListener('click', () => { + sidebar.classList.add('open'); + sidebarOverlay.classList.add('show'); + }); +} + +// Close sidebar when clicking overlay +if (sidebarOverlay) { + sidebarOverlay.addEventListener('click', () => { + sidebar.classList.remove('open'); + sidebarOverlay.classList.remove('show'); + }); +} + +tabBtns.forEach(btn => { + btn.addEventListener('click', () => { + switchTab(btn.dataset.tab); + }); +}); + +// initial auth check +authInitCheck(); +}); + +function switchTab(tab) { +tabBtns.forEach(btn => btn.classList.remove('active')); +chatTab.classList.remove('active'); +friendsTab.classList.remove('active'); +if (tab === 'chat') { + tabBtns[0].classList.add('active'); + chatTab.classList.add('active'); +} else { + tabBtns[1].classList.add('active'); + friendsTab.classList.add('active'); +} +} + +function selectDM(userIdArg, usernameArg) { +try { +currentDMUserId = userIdArg; +headerTitle.textContent = `DM with @${usernameArg}`; +// clear messages when switching to a DM conversation +if (messagesContainer) messagesContainer.innerHTML = ''; +lastFetchedTs = null; +isAtBottom = true; // reset scroll state for new conversation +currentChannelId = null; + +// Remove active class from ALL channels +document.querySelectorAll('.channel').forEach(ch => ch.classList.remove('active')); + +// mark active DM in the list +document.querySelectorAll('.dm-user').forEach(n => n.classList.remove('active')); +const activeEl = document.querySelector(`.dm-user[data-user-id="${userIdArg}"]`); +if (activeEl) activeEl.classList.add('active'); + +loadDM(userIdArg); +switchTab('chat'); +messageInput.value = ''; +} catch (e) { +console.error('selectDM error', e); +} +} \ No newline at end of file diff --git a/static/js/variables.js b/static/js/variables.js new file mode 100644 index 0000000..78e191c --- /dev/null +++ b/static/js/variables.js @@ -0,0 +1,69 @@ +// global variables and constants +let token = localStorage.getItem('token'); +let userId = localStorage.getItem('userId'); +let username = localStorage.getItem('username'); +let currentDMUserId = null; +let currentServerId = null; +let currentChannelId = null; +let servers = []; +let friends = []; +let allUsers = []; + +// reply functionality +let replyToMessage = null; // { id, username, content } +let replyToUsername = null; + +// unread message bookkeeping +let lastSeen = {}; // key -> timestamp ms of last seen message +let unreadCounts = {}; // key -> number of unread messages + +// notification state +let notificationPermission = Notification.permission; +let lastNotifiedMessages = {}; // key -> timestamp of last notified message + +function conversationKey(type, id) { + // type 'c' for channel, 'd' for dm + return `${type}${id}`; +} + +const API_URL = '/api'; // relative base for flask backend + +// DOM elements (will be assigned after DOM ready) +let authScreen, chatScreen; +let usernameInput, passwordInput; +let loginBtn, registerBtn, logoutBtn; +let messagesContainer, messageInput, sendBtn, fileInput, attachBtn, filePreview, filePreviewText, filePreviewClear; +let authMessage, currentUserDisplay, headerTitle; +let dmList, friendsList, friendRequestsList; +let addFriendBtn, addFriendModal, closeModal; +let friendUsernameInput, modalAddBtn, modalError; +let tabBtns, chatTab, friendsTab; +let profileBtn, profileModal, profileAvatarInput, profileDescInput, profilePronounsInput, profileSaveBtn, profileCloseBtn; +let userProfileModal, userProfileAvatar, userProfileUsername, userProfilePronouns, userProfileDescription; +let userProfileAddFriendBtn, userProfileCloseBtn; + +// Reply functionality DOM elements +let replyPreview, replyUsername, replyPreviewText, replyCancelBtn; + +// utility function available globally +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, m => map[m]); +} +// Stub functions for Service Worker updates (prevent errors if sw not implemented) +function updateServiceWorkerFriends() { + // Placeholder - implement if Service Worker is used + console.log('Service Worker friends update skipped'); +} + +function updateServiceWorkerChannels() { + // Placeholder - implement if Service Worker is used + console.log('Service Worker channels update skipped'); +} + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..d4e8c81 --- /dev/null +++ b/static/style.css @@ -0,0 +1,1207 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #0e1621; + color: #ffffff; + overflow: hidden; +} + +#app { + width: 100%; + height: 100vh; + background: #17212b; +} + +/* Auth Screen - Green Telegram style */ +.auth-container { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + background: linear-gradient(135deg, #17212b 0%, #0e1621 100%); +} + +.auth-box { + background: #17212b; + padding: 40px; + border-radius: 12px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3); + width: 100%; + max-width: 400px; + border: 1px solid #242f3d; +} + +.auth-box h1 { + color: #43b581; + text-align: center; + margin-bottom: 32px; + font-size: 28px; + font-weight: 600; +} + +#auth-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.auth-input { + background: #242f3d; + border: 1px solid #242f3d; + color: #ffffff; + padding: 12px 16px; + border-radius: 8px; + font-size: 15px; + transition: all 0.2s; +} + +.auth-input:focus { + outline: none; + border-color: #43b581; + background: #17212b; +} + +.auth-input::placeholder { + color: #7f91a4; +} + +.auth-btn { + background: #43b581; + border: none; + color: white; + padding: 14px; + border-radius: 8px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.auth-btn:hover { + background: #3aa373; + transform: translateY(-1px); +} + +.auth-btn.register-btn { + background: #242f3d; + color: #43b581; +} + +.auth-btn.register-btn:hover { + background: #2f3b49; +} + +.auth-message { + margin-top: 12px; + padding: 12px; + border-radius: 8px; + text-align: center; + font-size: 14px; + display: none; +} + +.auth-message.error { + display: block; + background: rgba(240, 71, 71, 0.15); + color: #f04747; +} + +.auth-message.success { + display: block; + background: rgba(67, 181, 129, 0.15); + color: #43b581; +} + +/* Chat Container */ +.chat-container { + display: flex; + height: 100vh; +} + +/* Servers Bar - Green */ +.servers-bar { + display: flex; + flex-direction: column; + align-items: center; + background: #17212b; + padding: 8px 0; + width: 72px; + border-right: 1px solid #0e1621; +} + +.servers-bar .server-icon { + width: 48px; + height: 48px; + background: linear-gradient(135deg, #43b581, #2d7a4f); + border-radius: 50%; + margin-bottom: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 18px; + transition: all 0.2s; +} + +.servers-bar .server-icon:hover { + border-radius: 16px; + transform: scale(1.05); +} + +.create-server-btn { + width: 48px; + height: 48px; + background: #242f3d; + color: #43b581; + border-radius: 50%; + font-size: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; +} + +.create-server-btn:hover { + background: #2f3b49; + border-radius: 16px; +} + +/* Sidebar */ +.sidebar { + display: flex; + flex-direction: column; + background: #17212b; + border-right: 1px solid #0e1621; + width: 320px; + flex-shrink: 0; +} + +.server-header { + padding: 16px; + border-bottom: 1px solid #242f3d; + display: flex; + justify-content: space-between; + align-items: center; + background: #242f3d; + gap: 12px; +} + +.server-name { + color: #ffffff; + font-weight: 600; + font-size: 16px; + flex: 1; +} + +.logout-btn { + background: none; + border: none; + color: #7f91a4; + cursor: pointer; + font-size: 20px; + padding: 6px; + border-radius: 6px; + transition: all 0.2s; + flex-shrink: 0; +} + +.logout-btn:hover { + background: #17212b; + color: #f04747; +} + +.sidebar-tabs { + display: flex; + gap: 4px; + padding: 12px; + border-bottom: 1px solid #242f3d; +} + +.tab-btn { + flex: 1; + background: #242f3d; + border: none; + color: #7f91a4; + padding: 10px; + cursor: pointer; + font-size: 14px; + text-align: center; + border-radius: 8px; + font-weight: 500; + transition: all 0.2s; +} + +.tab-btn:hover { + background: #2f3b49; + color: #ffffff; +} + +.tab-btn.active { + background: #43b581; + color: white; +} + +.tab-content { + flex: 1; + overflow-y: auto; + display: none; +} + +.tab-content.active { + display: block; +} + +.channels { + padding: 8px; + border-bottom: 1px solid #242f3d; +} + +.channel { + padding: 12px 16px; + color: #ffffff; + cursor: pointer; + font-size: 15px; + border-radius: 8px; + margin-bottom: 4px; + display: flex; + align-items: center; + justify-content: space-between; + transition: all 0.2s; +} + +.channel:hover { + background: #242f3d; +} + +.channel.active { + background: #43b581; + color: white; +} + +.create-channel-btn { + font-style: italic; + color: #43b581; +} + +.dm-header { + padding: 16px 16px 8px; + color: #7f91a4; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.dm-list { + flex: 1; + overflow-y: auto; + padding: 0 8px 8px; +} + +.dm-user, .friend-item { + padding: 12px 16px; + color: #ffffff; + border-radius: 8px; + cursor: pointer; + font-size: 15px; + transition: all 0.2s; + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 12px; +} + +.dm-user:hover, .friend-item:hover { + background: #242f3d; +} + +.dm-user.active { + background: #43b581; + color: white; +} + +/* Avatars - Round like Telegram */ +.msg-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.header-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + margin-right: 8px; + object-fit: cover; + flex-shrink: 0; +} + +/* User Badges */ +.user-badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 12px; + margin-left: 8px; + vertical-align: middle; + text-transform: uppercase; +} + +.user-badge.dev { + background: #9b59b6; + color: white; +} + +.user-badge.admin { + background: #e74c3c; + color: white; +} + +.user-badge.bot { + background: #3498db; + color: white; +} + +.user-badge.moderator { + background: #2ecc71; + color: white; +} + +.user-badge.vip { + background: #f39c12; + color: white; +} + +/* Friends Section */ +.friends-section { + padding: 16px; + border-bottom: 1px solid #242f3d; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + color: #7f91a4; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 12px; +} + +.add-friend-btn { + background: #43b581; + border: none; + color: white; + width: 28px; + height: 28px; + border-radius: 6px; + cursor: pointer; + font-size: 16px; + font-weight: 600; + transition: all 0.2s; +} + +.add-friend-btn:hover { + background: #3aa373; +} + +.friend-requests-list, .friends-list { + max-height: 200px; + overflow-y: auto; +} + +.friend-request-item { + padding: 12px; + background: #242f3d; + border-radius: 8px; + margin-bottom: 8px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.friend-request-item span { + color: #ffffff; + font-size: 14px; +} + +.request-actions { + display: flex; + gap: 8px; +} + +.accept-btn, .reject-btn { + border: none; + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all 0.2s; +} + +.accept-btn { + background: #43b581; + color: white; +} + +.reject-btn { + background: #f04747; + color: white; +} + +.empty-state { + padding: 20px; + color: #7f91a4; + font-size: 14px; + text-align: center; +} + +/* Main Content - Telegram style */ +.main-content { + flex: 1; + display: flex; + flex-direction: column; + background: #0e1621; +} + +.chat-header { + padding: 16px 20px; + border-bottom: 1px solid #242f3d; + display: flex; + justify-content: space-between; + align-items: center; + background: #17212b; + gap: 12px; +} + +.chat-header h2 { + color: #ffffff; + font-size: 18px; + font-weight: 600; +} + +.current-user { + color: #7f91a4; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + white-space: nowrap; +} + +.profile-btn { + background: #43b581; + border: none; + color: white; + font-size: 13px; + font-weight: 600; + cursor: pointer; + padding: 6px 12px; + border-radius: 6px; + transition: all 0.2s; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 4px; +} + +.profile-btn:hover { + background: #3aa373; + transform: translateY(-1px); +} + +/* Messages Container */ +.messages-container { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Message - Telegram bubble style with proper wrapping */ +.message { + display: flex; + gap: 12px; + max-width: 75%; + position: relative; + align-items: flex-start; +} + +.message.own { + align-self: flex-end; + flex-direction: row-reverse; +} + +.message-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + max-width: 100%; +} + +.message-header { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 4px; +} + +.message-author { + color: #43b581; + font-weight: 600; + font-size: 14px; + cursor: pointer; +} + +.message.own .message-author { + color: #64d2ff; +} + +.msg-pronouns { + font-size: 0.75rem; + color: #5a6570; /* более тусклый цвет */ + font-style: italic; /* только курсив */ + font-weight: normal; /* без жирности */ + margin-left: 6px; /* увеличенный отступ */ + opacity: 0.7; /* дополнительная тусклость */ +} + +.message-time { + color: #7f91a4; + font-size: 12px; +} + +.message.own .message-time { + color: #8fa8c1; +} + +/* Message bubble */ +.message-content-wrapper { + background: #17212b; + padding: 8px 12px; + border-radius: 12px; + border-bottom-left-radius: 4px; + border: 1px solid #242f3d; + word-wrap: break-word; + word-break: break-word; + overflow-wrap: break-word; + max-width: 100%; +} + +.message.own .message-content-wrapper { + background: #43b581; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 4px; + border-color: #43b581; +} + +.message-content { + color: #ffffff; + font-size: 15px; + line-height: 1.4; + white-space: pre-wrap; + word-wrap: break-word; + word-break: break-word; + overflow-wrap: break-word; + max-width: 100%; +} + +.message.own .message-content { + color: white; +} + +/* Message Actions */ +.message-actions { + position: absolute; + top: -8px; + right: 8px; + display: none; + gap: 4px; + z-index: 10; +} + +.message.own .message-actions { + right: auto; + left: 8px; +} + +.message:hover .message-actions { + display: flex; +} + +.msg-reply-btn { + background: #242f3d; + border: none; + color: #43b581; + width: 28px; + height: 28px; + border-radius: 50%; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.msg-reply-btn:hover { + background: #43b581; + color: white; +} + +/* Reply Indicator */ +.reply-indicator { + border-left: 3px solid #43b581; + padding-left: 10px; + margin-bottom: 8px; + color: #7f91a4; + font-size: 13px; + cursor: pointer; +} + +.reply-indicator:hover { + color: #43b581; +} + +/* Message Input Area */ +.message-input-area { + padding: 16px 20px; + border-top: 1px solid #242f3d; + display: flex; + gap: 12px; + align-items: center; + background: #17212b; +} + +.file-input-hidden { + display: none; +} + +.attach-btn { + background: transparent; + border: none; + color: #43b581; + font-size: 22px; + cursor: pointer; + padding: 8px; + border-radius: 50%; + transition: all 0.2s; +} + +.attach-btn:hover { + background: #242f3d; + transform: rotate(45deg); +} + +.file-preview { + padding: 12px 16px; + border-bottom: 1px solid #242f3d; + background: #242f3d; + display: none; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.file-preview-text { + color: #ffffff; + font-size: 14px; + word-break: break-all; + flex: 1; +} + +.file-preview-clear { + background: transparent; + border: none; + color: #7f91a4; + cursor: pointer; + font-size: 20px; + padding: 4px; + transition: all 0.2s; +} + +.file-preview-clear:hover { + color: #f04747; +} + +/* Reply Preview */ +.reply-preview { + padding: 12px 16px; + border-bottom: 1px solid #242f3d; + background: #242f3d; + display: none; + align-items: flex-start; + gap: 12px; + border-left: 3px solid #43b581; +} + +.reply-preview-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} + +.reply-preview-label { + color: #43b581; + font-size: 12px; + font-weight: 600; +} + +.reply-preview-text { + color: #7f91a4; + font-size: 14px; + white-space: pre-wrap; + word-break: break-word; +} + +.reply-cancel-btn { + background: transparent; + border: none; + color: #7f91a4; + cursor: pointer; + font-size: 20px; + padding: 4px; + transition: all 0.2s; +} + +.reply-cancel-btn:hover { + color: #f04747; +} + +.message-input { + flex: 1; + background: #242f3d; + border: 1px solid #242f3d; + color: #ffffff; + padding: 12px 16px; + border-radius: 20px; + font-size: 15px; + font-family: inherit; + resize: none; + height: 44px; + transition: all 0.2s; +} + +.message-input:focus { + outline: none; + border-color: #43b581; + background: #17212b; +} + +.message-input::placeholder { + color: #7f91a4; +} + +.send-btn { + background: #43b581; + border: none; + color: white; + padding: 12px 24px; + border-radius: 20px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.send-btn:hover { + background: #3aa373; + transform: translateY(-1px); +} + +/* Unread Badge */ +.unread-badge { + background: #f04747; + color: white; + border-radius: 10px; + padding: 2px 8px; + font-size: 12px; + font-weight: 600; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: #0e1621; +} + +::-webkit-scrollbar-thumb { + background: #242f3d; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #2f3b49; +} + +/* Sending state styles */ +.btn-disabled { + opacity: 0.6 !important; + cursor: not-allowed !important; + background: #365b3f !important; + transform: none !important; +} + +.input-disabled { + background: #2f3b49 !important; + border-color: #365b3f !important; + opacity: 0.7 !important; + cursor: not-allowed !important; + color: #7f91a4 !important; +} + +/* Modal */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + align-items: center; + justify-content: center; +} + +.modal-content { + background: #17212b; + padding: 32px; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + width: 100%; + max-width: 420px; + max-height: 500px; + overflow-y: auto; + border: 1px solid #242f3d; +} + +.modal-content h2 { + color: #ffffff; + margin-bottom: 20px; + font-size: 20px; + font-weight: 600; +} + +.close-modal { + color: #7f91a4; + float: right; + font-size: 28px; + cursor: pointer; + transition: color 0.2s; +} + +.close-modal:hover { + color: #ffffff; +} + +.modal-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.friend-input { + background: #242f3d; + border: 1px solid #242f3d; + color: #ffffff; + padding: 12px 16px; + border-radius: 8px; + font-size: 15px; + transition: all 0.2s; +} + +.friend-input:focus { + outline: none; + border-color: #43b581; +} + +.modal-submit-btn { + background: #43b581; + border: none; + color: white; + padding: 14px; + border-radius: 8px; + cursor: pointer; + font-size: 15px; + font-weight: 600; + transition: all 0.2s; +} + +.modal-submit-btn:hover { + background: #3aa373; +} + +.modal-error { + padding: 12px; + border-radius: 8px; + text-align: center; + font-size: 14px; + display: none; +} + +.modal-error.show { + display: block; + background: rgba(240, 71, 71, 0.15); + color: #f04747; +} + +.modal-error.show.success { + background: rgba(67, 181, 129, 0.15); + color: #43b581; +} + +/* User Profile Modal */ +/* User Profile Modal - FIXED */ +.user-profile-modal { + z-index: 2000; +} + +.user-profile-content { + background: #1e3c2a; + border-radius: 12px; + width: 340px; + overflow: hidden; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + border: 1px solid #5a8f56; +} + +.user-profile-header { + position: relative; + height: 100px; +} + +.user-profile-banner { + height: 100%; + background: linear-gradient(135deg, #43b581, #2d7a4f); +} + +.user-profile-close { + position: absolute; + top: 10px; + right: 10px; + color: white; + font-size: 24px; + cursor: pointer; + z-index: 10; + background: rgba(0, 0, 0, 0.3); + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.user-profile-close:hover { + background: rgba(0, 0, 0, 0.5); + transform: scale(1.1); +} + +.user-profile-body { + padding: 0 20px 20px; + position: relative; +} + +.user-profile-avatar-container { + position: absolute; + top: -50px; + left: 20px; +} + +.user-profile-avatar { + width: 100px; + height: 100px; + border-radius: 50%; + border: 4px solid #1e3c2a; + object-fit: cover; + background: #2b542f; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.user-profile-info { + padding-top: 55px; + padding-bottom: 12px; + border-bottom: 1px solid #365b3f; + margin-bottom: 16px; +} + +.user-profile-username { + color: #ffffff; + font-size: 20px; + font-weight: 700; + margin: 0; +} + +.user-profile-pronouns { + color: #8b949e; + font-size: 13px; + margin-top: 4px; + display: block; +} + +.user-profile-description { + color: #d4e6d3; + font-size: 14px; + line-height: 1.5; + margin-top: 8px; + min-height: 20px; +} + +.user-profile-description:empty::before { + content: 'Нет описания'; + color: #8b949e; + font-style: italic; +} + +.user-profile-add-friend-btn { + width: 100%; + margin-top: 16px; + padding: 12px; + background: #43b581; + border: none; + border-radius: 8px; + color: white; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.user-profile-add-friend-btn:hover { + background: #3aa373; + transform: translateY(-1px); +} + +.user-profile-add-friend-btn:disabled { + background: #365b3f; + cursor: not-allowed; +} + +.user-profile-add-friend-btn.added { + background: #365b3f; +} + +/* Sending state styles */ +.btn-disabled { + opacity: 0.6 !important; + cursor: not-allowed !important; + background: #365b3f !important; + transform: none !important; +} + +.input-disabled { + background: #2f3b49 !important; + border-color: #365b3f !important; + opacity: 0.7 !important; + cursor: not-allowed !important; + color: #7f91a4 !important; +} + +/* Sidebar Toggle */ +.sidebar-toggle { + display: none; +} + +.sidebar-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 99; +} + +.sidebar-overlay.show { + display: block; +} + +/* Media attachments */ +.msg-image, .msg-video, .msg-audio { + display: block; + margin-top: 8px; + max-width: 100%; + border-radius: 8px; +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.message { + animation: fadeIn 0.3s ease; +} + +/* Responsive */ +@media (max-width: 768px) { + .servers-bar { + display: none; + } + + .sidebar { + width: 320px; + position: fixed; + height: 100%; + z-index: 100; + left: -320px; + transition: left 0.3s ease; + } + + .sidebar.open { + left: 0; + } + + .sidebar-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: #242f3d; + border-radius: 8px; + color: #43b581; + font-size: 20px; + cursor: pointer; + position: fixed; + top: 12px; + left: 12px; + z-index: 101; + } + + .main-content { + width: 100%; + } + + .message { + max-width: 85%; + } +} \ No newline at end of file diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..ec84e24 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,315 @@ + + + + + + Admin Dashboard + + + +
+
+
+
+ ⚙️ +
+
+ +
+
+

Admin Dashboard

+
+ +
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/templates/dev.html b/templates/dev.html new file mode 100644 index 0000000..125378b --- /dev/null +++ b/templates/dev.html @@ -0,0 +1,660 @@ + + + + + + Dev Dashboard + + + + + + + + +
+ +
+

System Overview

+
+
+
storage Database Size
+

-- MB

+
+
+
person Active Connections
+

--

+
+
+
message Total Messages
+

--

+
+
+
schedule Uptime
+

--

+
+
+ +
+ +
+

User Management

+
+ + + + + + + + + + + + +
IDUsernameRoleStatusActions
+
+
+ +
+

Content Moderation

+
+
+
Select Server & Channel
+
+ +
+
+ +
+
+
+
+ Messages + +
+
+ + + + + + + + + + + + + +
AuthorContentDateAction
Select a channel to view messages
+
+
+
+
+ +
+

Invite Codes

+
+
+
Generate Codes
+
+ +
+ +
+
+ +
+ +
+
+
Active Unused Codes
+
+ + + +
+
+
+
+
+ +
+

System Operations

+
+
+
campaign Global Broadcast
+

Sends a database message to ALL channels and ALL users (DMs).

+
+ +
+ +
+ +
+
notifications_active SSE Notification
+

Pushes a live popup notification to currently connected clients.

+
+ +
+ +
+ +
+
restart_alt Server Management
+

Requires systemd and sudo permissions set up on the host.

+ +
+
+
+ +
+ +
+ + + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7bb93dd --- /dev/null +++ b/templates/index.html @@ -0,0 +1,178 @@ + + + + + + Это днище интернета 2 + + + +
+ +
+
+

Это днище интернета 2

+
+ + + + +
+
+
+
+ + + +
+ + + + + + + + + + + \ No newline at end of file