uploaded all code

This commit is contained in:
lohrrrr 2026-06-08 21:56:14 +00:00
parent 19fdfc7a1f
commit ffe5a796a0
24 changed files with 5228 additions and 0 deletions

94
app.py Normal file
View file

@ -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)

1
blueprints/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Blueprints package initialization."""

116
blueprints/admin.py Normal file
View file

@ -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/<int:msg_id>', 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')

61
blueprints/auth.py Normal file
View file

@ -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

174
blueprints/chat.py Normal file
View file

@ -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/<int:server_id>/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/<int:user_id>', 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

131
blueprints/dev.py Normal file
View file

@ -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')

66
blueprints/files.py Normal file
View file

@ -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/<filename>', 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

172
blueprints/users.py Normal file
View file

@ -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/<int:user_id>', 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/<int:request_id>', 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

111
database.py Normal file
View file

@ -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()

99
db.py Normal file
View file

@ -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)

95
decorators.py Normal file
View file

@ -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

6
requirements.txt Normal file
View file

@ -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

19
static/custom_502.html Normal file
View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Технические работы</title>
<style>
body { font-family: "MS Sans Serif", "Courier New", sans-serif; background-color: #c0c0c0; text-align: center; padding: 50px; }
.window { background: #c0c0c0; border: 2px solid; border-color: #fff #808080 #808080 #fff; display: inline-block; padding: 20px; box-shadow: 2px 2px 10px rgba(0,0,0,0.5); }
h1 { color: #000080; font-size: 18px; margin-bottom: 20px; }
</style>
</head>
<body>
<div class="window">
<h1>502 — Технические работы</h1>
<p>Вы не волнуйтесь, это просто небольшие технические работы для загрузки нового кода на сервер.</p>
<p>Это обычно занимает от 5 до 10 минут.</p>
</div>
</body>
</html>

183
static/js/auth.js Normal file
View file

@ -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();
}
}

630
static/js/chat.js Normal file
View file

@ -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) + '<br/>';
}
const url = file.url;
const mime = file.mimetype || '';
if (mime.startsWith('image/')) {
html += `<img src="${url}" class="msg-image"/>`;
} else if (mime.startsWith('video/')) {
html += `<video controls class="msg-video"><source src="${url}" type="${mime}"></video>`;
} else if (mime.startsWith('audio/')) {
html += `<audio controls class="msg-audio"><source src="${url}" type="${mime}"></audio>`;
} else {
html += `<a href="${url}" download="${escapeHtml(file.filename)}">${escapeHtml(file.filename)}</a> (${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 = `<img class="msg-avatar" src="${msg.avatar_url}" alt="avatar"/>`;
}
let badgeHtml = '';
if (msg.badge) {
const badgeClass = msg.badge.toLowerCase().replace(/\s+/g, '-');
badgeHtml = `<span class="user-badge ${badgeClass}">${msg.badge}</span>`;
}
let pron = msg.pronouns ? `<span class="msg-pronouns"> (${msg.pronouns})</span>` : '';
const { dateStr, timeStr } = formatLocalTime(msg.timestamp);
const renderedContent = renderMessageContent(msg.content);
let replyIndicatorHtml = '';
if (msg.reply_to) {
replyIndicatorHtml = `<div class="reply-indicator" data-reply-to="${msg.reply_to}" style="cursor: pointer;">Replying to message #${msg.reply_to}</div>`;
}
// FIXED: Proper Telegram-style message structure
msgEl.innerHTML = `
<div class="message-actions">
<button class="msg-reply-btn" data-msg-id="${msgId}" data-username="${msg.username}" title="Reply"></button>
</div>
${avatarHtml}
<div class="message-body">
<div class="message-header">
<span class="message-author">${msg.username}${badgeHtml}${pron}</span>
<span class="message-time">${dateStr} ${timeStr}</span>
</div>
<div class="message-content-wrapper">
${replyIndicatorHtml}
<div class="message-content">${renderedContent}</div>
</div>
</div>
`;
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);
}
}

205
static/js/friends.js Normal file
View file

@ -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 = '<div class="empty-state">No friends yet. Add some!</div>';
return;
}
friends.forEach(friend => {
const friendEl = document.createElement('div');
friendEl.className = 'friend-item';
let av = friend.avatar_url ? `<img class="msg-avatar" src="${friend.avatar_url}" alt="av"/>` : '';
// Generate badge HTML if user has a system badge
let badgeHtml = '';
if (friend.badge) {
const badgeClass = friend.badge.toLowerCase().replace(/\s+/g, '-');
badgeHtml = `<span class="user-badge ${badgeClass}">${friend.badge}</span>`;
}
friendEl.innerHTML = `${av}<span>@${friend.username}</span>${badgeHtml}`;
friendEl.onclick = () => selectDM(friend.id, friend.username);
friendsList.appendChild(friendEl);
});
}
function displayDMList() {
dmList.innerHTML = '';
if (friends.length === 0) {
dmList.innerHTML = '<div class="empty-state">No friends</div>';
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 ? `<img class="msg-avatar" src="${friend.avatar_url}" alt="av"/>` : '';
// Generate badge HTML if user has a system badge
let badgeHtml = '';
if (friend.badge) {
const badgeClass = friend.badge.toLowerCase().replace(/\s+/g, '-');
badgeHtml = `<span class="user-badge ${badgeClass}">${friend.badge}</span>`;
}
userEl.innerHTML = `${av}<span>@${friend.username}</span>${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 = '<div class="empty-state">No pending requests</div>';
return;
}
requests.forEach(req => {
const reqEl = document.createElement('div');
reqEl.className = 'friend-request-item';
reqEl.innerHTML = `
<span>@${req.username}</span>
<div class="request-actions">
<button class="accept-btn" onclick="respondFriendRequest(${req.id}, 'accept')"></button>
<button class="reject-btn" onclick="respondFriendRequest(${req.id}, 'reject')"></button>
</div>
`;
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);
}
});
}

360
static/js/profile.js Normal file
View file

@ -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);
}
});
}
}

145
static/js/servers.js Normal file
View file

@ -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');
}
});
}
}

131
static/js/ui.js Normal file
View file

@ -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);
}
}

69
static/js/variables.js Normal file
View file

@ -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 = {
'&': '&amp;',
'<': '<',
'>': '>',
'"': '"',
"'": '&#039;'
};
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');
}

1207
static/style.css Normal file

File diff suppressed because it is too large Load diff

315
templates/admin.html Normal file
View file

@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div id="app">
<div class="chat-container">
<div class="servers-bar">
<div class="server-icon active" data-server="admin">
<span>⚙️</span>
</div>
</div>
<div class="sidebar">
<div class="server-header">
<div class="server-name">Admin Panel</div>
<button id="logout-btn" class="logout-btn"></button>
</div>
<div class="sidebar-tabs">
<button class="tab-btn active" data-tab="users">👥 Users</button>
<button class="tab-btn" data-tab="bulk">🗑️ Bulk Delete</button>
</div>
<div id="users-tab" class="tab-content active">
<input type="text" id="userSearch" placeholder="Search users..." class="auth-input">
<div id="usersList" class="channels"></div>
</div>
<div id="bulk-tab" class="tab-content">
<div class="bulk-delete-ui">
<div>
<button id="loadServersBtn" class="auth-btn">Load Servers</button>
<select id="serverSelect" class="auth-input">
<option>Select server</option>
</select>
</div>
<div>
<button id="loadChannelsBtn" class="auth-btn">Load Channels</button>
<select id="channelSelect" class="auth-input">
<option>Select channel</option>
</select>
</div>
<div>
<button id="loadMessagesBtn" class="auth-btn">Load Messages</button>
</div>
<button id="bulkDeleteBtn" class="auth-btn" disabled>Bulk Delete Selected</button>
<div id="bulkStatus" class="auth-message"></div>
</div>
</div>
</div>
<div class="main-content">
<div class="chat-header">
<h2>Admin Dashboard</h2>
<div class="current-user">
<span id="current-user-name"></span>
</div>
</div>
<div class="messages-container" id="adminMessages">
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
document.getElementById(btn.dataset.tab + '-tab').classList.add('active');
if (btn.dataset.tab === 'users') {
loadGlobalMessages();
}
};
});
var token = localStorage.getItem('token');
if (!token) {
window.location.href = '/';
return;
}
// Common
const logoutBtn = document.getElementById('logout-btn');
const currentUserName = document.getElementById('current-user-name');
logoutBtn.onclick = () => {
localStorage.removeItem('token');
window.location.href = '/';
};
fetch('/api/profile', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(res => res.json()).then(user => {
currentUserName.textContent = user.username + (user.badge ? ' (' + user.badge + ')' : '');
}).catch(() => window.location.href = '/');
// Users tab
const userSearch = document.getElementById('userSearch');
const usersList = document.getElementById('usersList');
userSearch.oninput = loadUsers;
async function loadUsers() {
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
});
const users = await res.json();
const search = userSearch.value.toLowerCase();
usersList.innerHTML = '';
users.filter(u => u.username.toLowerCase().includes(search)).forEach(u => {
const div = document.createElement('div');
div.className = 'channel';
const badgeHtml = u.badge ? '<span class="user-badge ' + u.badge.toLowerCase() + '">' + u.badge + '</span>' : '';
const bannedHtml = u.is_banned ? '<span style="color: #f04747;">(Banned)</span>' : '';
div.innerHTML = '<span>' + u.username + ' ' + badgeHtml + ' ' + bannedHtml + '</span>' +
'<button class="auth-btn" style="padding: 4px 12px; font-size: 12px;" onclick="toggleBan(' + u.id + ', ' + u.is_banned + ')">' +
(u.is_banned ? 'Unban' : 'Ban') + '</button>';
usersList.appendChild(div);
});
} catch (e) {
console.error(e);
}
}
window.toggleBan = async (id, banned) => {
try {
await fetch('/api/admin/ban', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id, ban: banned ? 0 : 1 })
});
loadUsers();
} catch (e) {
alert(e.message);
}
};
// Messages display
async function loadGlobalMessages() {
try {
const res = await fetch('/api/messages', {
headers: { 'Authorization': 'Bearer ' + token }
});
const msgs = await res.json();
const container = document.getElementById('adminMessages');
container.innerHTML = '';
msgs.slice(0, 20).forEach(m => {
const div = document.createElement('div');
div.className = 'message';
const displayContent = m.content.length > 100 ? m.content.substring(0, 100) + '...' : m.content;
div.innerHTML =
'<div class="msg-avatar" style="background: #43b581;"></div>' +
'<div class="message-body">' +
'<div class="message-header">' +
'<span class="message-author">' + m.username + '</span>' +
'<span class="msg-pronouns">' + (m.pronouns || '') + '</span>' +
'<span class="message-time">' + m.timestamp + '</span>' +
'</div>' +
'<div class="message-content-wrapper">' +
'<div class="message-content">' + displayContent + '</div>' +
'</div>' +
'</div>';
container.appendChild(div);
});
// Autoscroll to bottom
container.scrollTop = container.scrollHeight;
} catch (e) {
console.error('Load messages:', e);
}
}
// Bulk delete
const loadServersBtn = document.getElementById('loadServersBtn');
const serverSelect = document.getElementById('serverSelect');
const loadChannelsBtn = document.getElementById('loadChannelsBtn');
const channelSelect = document.getElementById('channelSelect');
const loadMessagesBtn = document.getElementById('loadMessagesBtn');
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
const bulkStatus = document.getElementById('bulkStatus');
let selectedMessages = [];
loadServersBtn.onclick = async () => {
try {
const res = await fetch('/api/admin/servers', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
});
const servers = await res.json();
serverSelect.innerHTML = '<option>Select server</option>';
servers.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
serverSelect.appendChild(opt);
});
} catch (e) {
alert(e.message);
}
};
loadChannelsBtn.onclick = async () => {
const serverId = serverSelect.value;
if (!serverId) return alert('Select server first');
try {
const res = await fetch('/api/admin/channels', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ server_id: serverId })
});
const channels = await res.json();
channelSelect.innerHTML = '<option>Select channel</option>';
channels.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
channelSelect.appendChild(opt);
});
} catch (e) {
alert(e.message);
}
};
loadMessagesBtn.onclick = async () => {
const channelId = channelSelect.value;
if (!channelId) {
alert('Please select a channel first.');
return;
}
try {
const res = await fetch('/api/admin/messages', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: channelId })
});
if (!res.ok) {
throw new Error('Failed to load messages');
}
const msgs = await res.json();
const container = document.getElementById('adminMessages');
container.innerHTML = '';
selectedMessages = [];
if (msgs.length === 0) {
container.innerHTML = '<div class="message">No messages found in this channel.</div>';
} else {
msgs.forEach(m => {
const div = document.createElement('div');
div.className = 'message';
const displayContent = m.content.length > 100 ? m.content.substring(0, 100) + '...' : m.content;
div.innerHTML =
'<div class="msg-avatar" style="background: #43b581;"></div>' +
'<div class="message-body">' +
'<div class="message-header">' +
'<input type="checkbox" id="msg-checkbox-' + m.id + '" onchange="toggleMsgSelect(' + m.id + ')" style="margin-right: 10px;">' +
'<span class="message-author">' + m.username + '</span>' +
'<span class="msg-pronouns">' + (m.pronouns || '') + '</span>' +
'<span class="message-time">' + m.timestamp + '</span>' +
'</div>' +
'<div class="message-content-wrapper">' +
'<div class="message-content">' + displayContent + '</div>' +
'</div>' +
'</div>';
container.appendChild(div);
});
}
bulkDeleteBtn.disabled = true;
bulkDeleteBtn.textContent = 'Bulk Delete Selected';
bulkStatus.textContent = '';
// Autoscroll to bottom
container.scrollTop = container.scrollHeight;
} catch (e) {
alert('Error loading messages: ' + e.message);
console.error(e);
}
};
window.toggleMsgSelect = (id) => {
if (selectedMessages.includes(id)) {
selectedMessages = selectedMessages.filter(x => x !== id);
} else {
selectedMessages.push(id);
}
bulkDeleteBtn.textContent = 'Delete ' + selectedMessages.length + ' selected';
bulkDeleteBtn.disabled = selectedMessages.length === 0;
};
bulkDeleteBtn.onclick = async () => {
if (selectedMessages.length === 0) return;
if (!confirm('Delete ' + selectedMessages.length + ' messages?')) return;
try {
const res = await fetch('/api/admin/bulk-delete', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ message_ids: selectedMessages })
});
const result = await res.json();
bulkStatus.textContent = 'Deleted ' + result.deleted + ' messages';
bulkStatus.className = 'auth-message success';
selectedMessages = [];
bulkDeleteBtn.disabled = true;
bulkDeleteBtn.textContent = 'Bulk Delete Selected';
loadMessagesBtn.click(); // reload
} catch (e) {
alert(e.message);
}
};
loadUsers();
loadGlobalMessages();
});
</script>
</body>
</html>

660
templates/dev.html Normal file
View file

@ -0,0 +1,660 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dev Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet">
<style>
:root {
/* Material You Inspired Color Palette */
--md-sys-color-primary: #6750A4;
--md-sys-color-on-primary: #FFFFFF;
--md-sys-color-primary-container: #EADDFF;
--md-sys-color-on-primary-container: #21005D;
--md-sys-color-secondary-container: #E8DEF8;
--md-sys-color-on-secondary-container: #1D192B;
--md-sys-color-error: #B3261E;
--md-sys-color-error-container: #F9DEDC;
--md-sys-color-on-error-container: #410E0B;
--md-sys-color-background: #FEF7FF;
--md-sys-color-on-background: #1D1B20;
--md-sys-color-surface: #F3EDF7;
--md-sys-color-on-surface: #1D1B20;
--md-sys-color-outline: #79747E;
--border-radius-large: 24px;
--border-radius-medium: 16px;
--border-radius-small: 8px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
}
body {
background-color: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
display: flex;
height: 100vh;
overflow: hidden;
}
/* Sidebar Navigation */
.sidebar {
width: 280px;
background-color: var(--md-sys-color-surface);
padding: 24px 12px;
display: flex;
flex-direction: column;
gap: 8px;
border-right: 1px solid var(--md-sys-color-outline);
box-shadow: 2px 0 8px rgba(0,0,0,0.05);
}
.sidebar-header {
padding: 0 16px 24px 16px;
font-size: 1.5rem;
font-weight: 700;
color: var(--md-sys-color-primary);
display: flex;
align-items: center;
gap: 12px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 100px;
cursor: pointer;
color: var(--md-sys-color-on-surface);
transition: background-color 0.2s, color 0.2s;
font-weight: 500;
}
.nav-item:hover {
background-color: var(--md-sys-color-secondary-container);
}
.nav-item.active {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
/* Main Content Area */
.main-content {
flex: 1;
padding: 32px;
overflow-y: auto;
}
.section {
display: none;
animation: fadeIn 0.3s ease-in-out;
}
.section.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
h2 {
font-size: 2rem;
margin-bottom: 24px;
color: var(--md-sys-color-primary);
}
/* Material Cards */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 24px;
}
.card {
background-color: var(--md-sys-color-surface);
border-radius: var(--border-radius-large);
padding: 24px;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
}
.card-title {
font-size: 1.25rem;
font-weight: 500;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
/* Forms & Inputs */
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
input, select, textarea {
padding: 16px;
border-radius: var(--border-radius-small);
border: 1px solid var(--md-sys-color-outline);
background: transparent;
font-size: 1rem;
color: var(--md-sys-color-on-surface);
outline: none;
transition: border-color 0.2s;
}
input:focus, select:focus, textarea:focus {
border-color: var(--md-sys-color-primary);
border-width: 2px;
padding: 15px; /* Offset border width jump */
}
/* Buttons */
.btn {
padding: 12px 24px;
border-radius: 100px;
border: none;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.btn-primary {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.btn-primary:hover {
box-shadow: 0 2px 8px rgba(103, 80, 164, 0.4);
}
.btn-error {
background-color: var(--md-sys-color-error);
color: white;
}
/* Tables */
.table-container {
background: var(--md-sys-color-surface);
border-radius: var(--border-radius-large);
overflow: hidden;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
th, td {
padding: 16px;
border-bottom: 1px solid var(--md-sys-color-outline);
}
th {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
font-weight: 500;
}
/* Badges */
.badge {
padding: 4px 8px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: bold;
background: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
.badge.banned {
background: var(--md-sys-color-error-container);
color: var(--md-sys-color-on-error-container);
}
/* Toast Notifications */
#toast-container {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 1000;
}
.toast {
background-color: #322F35;
color: #F5EFF7;
padding: 16px 24px;
border-radius: var(--border-radius-small);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: flex;
align-items: center;
gap: 12px;
animation: slideIn 0.3s ease-out forwards;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
</head>
<body>
<nav class="sidebar">
<div class="sidebar-header">
<span class="material-icons-round">terminal</span>
DevSpace
</div>
<div class="nav-item active" onclick="switchTab('dashboard')">
<span class="material-icons-round">dashboard</span> System Overview
</div>
<div class="nav-item" onclick="switchTab('users'); loadUsers();">
<span class="material-icons-round">people</span> User Management
</div>
<div class="nav-item" onclick="switchTab('content'); loadServers();">
<span class="material-icons-round">forum</span> Content Moderation
</div>
<div class="nav-item" onclick="switchTab('codes'); loadCodes();">
<span class="material-icons-round">vpn_key</span> Invite Codes
</div>
<div class="nav-item" onclick="switchTab('system')">
<span class="material-icons-round">settings_power</span> System Operations
</div>
</nav>
<main class="main-content">
<section id="dashboard" class="section active">
<h2>System Overview</h2>
<div class="card-grid">
<div class="card">
<div class="card-title"><span class="material-icons-round">storage</span> Database Size</div>
<h1 id="stat-db-size">-- MB</h1>
</div>
<div class="card">
<div class="card-title"><span class="material-icons-round">person</span> Active Connections</div>
<h1 id="stat-users">--</h1>
</div>
<div class="card">
<div class="card-title"><span class="material-icons-round">message</span> Total Messages</div>
<h1 id="stat-messages">--</h1>
</div>
<div class="card">
<div class="card-title"><span class="material-icons-round">schedule</span> Uptime</div>
<h1 id="stat-uptime" style="font-size: 1.5rem;">--</h1>
</div>
</div>
<button class="btn btn-primary" onclick="loadStats()">
<span class="material-icons-round">refresh</span> Refresh Stats
</button>
</section>
<section id="users" class="section">
<h2>User Management</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="users-table-body">
</tbody>
</table>
</div>
</section>
<section id="content" class="section">
<h2>Content Moderation</h2>
<div class="card-grid">
<div class="card">
<div class="card-title">Select Server & Channel</div>
<div class="input-group">
<select id="server-select" onchange="loadChannels()"><option>Loading...</option></select>
</div>
<div class="input-group">
<select id="channel-select" onchange="loadMessages()"><option>Select Server First</option></select>
</div>
</div>
<div class="card" style="grid-column: span 2;">
<div class="card-title" style="justify-content: space-between;">
<span>Messages</span>
<button class="btn btn-error" onclick="bulkDelete()">Bulk Delete Selected</button>
</div>
<div class="table-container" style="max-height: 400px; overflow-y: auto;">
<table>
<thead>
<tr>
<th><input type="checkbox" id="select-all" onclick="toggleSelectAll()"></th>
<th>Author</th>
<th>Content</th>
<th>Date</th>
<th>Action</th>
</tr>
</thead>
<tbody id="messages-table-body">
<tr><td colspan="5">Select a channel to view messages</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<section id="codes" class="section">
<h2>Invite Codes</h2>
<div class="card-grid">
<div class="card">
<div class="card-title">Generate Codes</div>
<div class="input-group">
<input type="number" id="code-count" placeholder="Number of codes (default 10)" min="1">
</div>
<button class="btn btn-primary" onclick="generateCodes(false)">Generate Random</button>
<hr style="margin: 24px 0; border: 0; border-top: 1px solid var(--md-sys-color-outline);">
<div class="input-group">
<input type="text" id="custom-code" placeholder="Custom Code (e.g. SUMMER2026)">
</div>
<button class="btn btn-primary" onclick="generateCodes(true)">Create Custom</button>
</div>
<div class="card">
<div class="card-title">Active Unused Codes</div>
<div class="table-container" style="max-height: 300px; overflow-y: auto;">
<table>
<tbody id="codes-table-body">
</tbody>
</table>
</div>
</div>
</div>
</section>
<section id="system" class="section">
<h2>System Operations</h2>
<div class="card-grid">
<div class="card">
<div class="card-title"><span class="material-icons-round">campaign</span> Global Broadcast</div>
<p style="margin-bottom: 16px; font-size: 0.9rem;">Sends a database message to ALL channels and ALL users (DMs).</p>
<div class="input-group">
<textarea id="global-msg" rows="3" placeholder="Enter broadcast message..."></textarea>
</div>
<button class="btn btn-error" onclick="sendBroadcast('global')">Send Global Message</button>
</div>
<div class="card">
<div class="card-title"><span class="material-icons-round">notifications_active</span> SSE Notification</div>
<p style="margin-bottom: 16px; font-size: 0.9rem;">Pushes a live popup notification to currently connected clients.</p>
<div class="input-group">
<textarea id="sse-msg" rows="3" placeholder="Enter notification message..."></textarea>
</div>
<button class="btn btn-primary" onclick="sendBroadcast('sse')">Send Live Alert</button>
</div>
<div class="card">
<div class="card-title"><span class="material-icons-round">restart_alt</span> Server Management</div>
<p style="margin-bottom: 16px; font-size: 0.9rem;">Requires systemd and sudo permissions set up on the host.</p>
<button class="btn btn-error" onclick="restartServer()">Restart dnishe.service</button>
</div>
</div>
</section>
</main>
<div id="toast-container"></div>
<script>
// --- Utilities ---
const API_BASE = '/api';
function switchTab(tabId) {
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
event.currentTarget.classList.add('active');
}
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
const icon = type === 'error' ? 'error' : (type === 'success' ? 'check_circle' : 'info');
toast.innerHTML = `<span class="material-icons-round">${icon}</span> ${message}`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
async function apiCall(endpoint, method = 'GET', body = null) {
const options = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include' // Important for auth_token cookie
};
if (body) options.body = JSON.stringify(body);
try {
const res = await fetch(`${API_BASE}${endpoint}`, options);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'API Error');
return data;
} catch (err) {
showToast(err.message, 'error');
throw err;
}
}
// --- Dashboard Stats ---
async function loadStats() {
try {
const stats = await apiCall('/dev/system-stats', 'POST'); // app.py expects POST
document.getElementById('stat-db-size').innerText = `${stats.db_size_mb} MB`;
document.getElementById('stat-users').innerText = stats.active_users;
document.getElementById('stat-messages').innerText = stats.message_count;
document.getElementById('stat-uptime').innerText = stats.uptime;
} catch (e) {}
}
// --- User Management ---
async function loadUsers() {
try {
const users = await apiCall('/admin/users', 'POST'); // app.py expects POST
const tbody = document.getElementById('users-table-body');
tbody.innerHTML = '';
users.forEach(u => {
const statusBadge = u.is_banned
? `<span class="badge banned">Banned</span>`
: `<span class="badge">Active</span>`;
const actionBtn = u.is_banned
? `<button class="btn btn-primary" onclick="toggleBan(${u.id}, 0)">Unban</button>`
: `<button class="btn btn-error" onclick="toggleBan(${u.id}, 1)">Ban</button>`;
tbody.innerHTML += `
<tr>
<td>${u.id}</td>
<td>${u.username}</td>
<td><span class="badge">${u.badge || 'User'}</span></td>
<td>${statusBadge}</td>
<td>${actionBtn}</td>
</tr>
`;
});
} catch (e) {}
}
async function toggleBan(userId, banStatus) {
try {
await apiCall('/admin/ban', 'POST', { user_id: userId, ban: banStatus });
showToast(`User ${banStatus ? 'banned' : 'unbanned'} successfully`, 'success');
loadUsers();
} catch (e) {}
}
// --- Content Moderation ---
async function loadServers() {
try {
const servers = await apiCall('/admin/servers', 'POST'); // app.py expects POST
const select = document.getElementById('server-select');
select.innerHTML = '<option value="">Select a Server</option>';
servers.forEach(s => {
select.innerHTML += `<option value="${s.id}">${s.name}</option>`;
});
} catch (e) {}
}
async function loadChannels() {
const serverId = document.getElementById('server-select').value;
if (!serverId) return;
try {
const channels = await apiCall('/admin/channels', 'POST', { server_id: serverId });
const select = document.getElementById('channel-select');
select.innerHTML = '<option value="">Select a Channel</option>';
channels.forEach(c => {
select.innerHTML += `<option value="${c.id}">${c.name}</option>`;
});
} catch (e) {}
}
async function loadMessages() {
const channelId = document.getElementById('channel-select').value;
if (!channelId) return;
try {
const messages = await apiCall('/admin/messages', 'POST', { channel_id: channelId });
const tbody = document.getElementById('messages-table-body');
tbody.innerHTML = '';
if(messages.length === 0) {
tbody.innerHTML = '<tr><td colspan="5">No messages found.</td></tr>';
return;
}
messages.forEach(m => {
let content = m.content;
try { content = JSON.parse(m.content).text || "[File Attachment]"; } catch(e){}
tbody.innerHTML += `
<tr>
<td><input type="checkbox" class="msg-checkbox" value="${m.id}"></td>
<td>${m.username}</td>
<td>${content}</td>
<td>${new Date(m.timestamp).toLocaleString()}</td>
<td><button class="btn btn-error" style="padding: 8px 16px;" onclick="deleteMessage(${m.id})">Delete</button></td>
</tr>
`;
});
} catch (e) {}
}
async function deleteMessage(msgId) {
if(!confirm("Delete this message?")) return;
try {
await apiCall(`/admin/message/${msgId}`, 'DELETE');
showToast("Message deleted", "success");
loadMessages();
} catch(e) {}
}
function toggleSelectAll() {
const isChecked = document.getElementById('select-all').checked;
document.querySelectorAll('.msg-checkbox').forEach(cb => cb.checked = isChecked);
}
async function bulkDelete() {
const selected = Array.from(document.querySelectorAll('.msg-checkbox:checked')).map(cb => parseInt(cb.value));
if(selected.length === 0) return showToast("No messages selected", "error");
if(!confirm(`Delete ${selected.length} messages?`)) return;
try {
await apiCall('/admin/bulk-delete', 'POST', { message_ids: selected });
showToast(`Deleted ${selected.length} messages`, "success");
loadMessages();
} catch(e) {}
}
// --- Invite Codes ---
async function loadCodes() {
try {
const codes = await apiCall('/dev/list-codes', 'GET');
const tbody = document.getElementById('codes-table-body');
tbody.innerHTML = '';
codes.forEach(c => {
tbody.innerHTML += `<tr><td style="font-family: monospace; font-size: 1.1rem;">${c.code}</td></tr>`;
});
} catch (e) {}
}
async function generateCodes(isCustom) {
const payload = {};
if (isCustom) {
payload.custom = document.getElementById('custom-code').value;
if(!payload.custom) return showToast('Enter a custom code', 'error');
} else {
payload.num = parseInt(document.getElementById('code-count').value) || 10;
}
try {
await apiCall('/dev/generate-code', 'POST', payload);
showToast('Codes generated successfully', 'success');
if(isCustom) document.getElementById('custom-code').value = '';
loadCodes();
} catch (e) {}
}
// --- System Operations ---
async function sendBroadcast(type) {
const isGlobal = type === 'global';
const endpoint = isGlobal ? '/dev/global-broadcast' : '/dev/broadcast';
const inputId = isGlobal ? 'global-msg' : 'sse-msg';
const message = document.getElementById(inputId).value;
if(!message) return showToast('Message cannot be empty', 'error');
if(isGlobal && !confirm("WARNING: This will insert a DB message into EVERY channel and DM. Proceed?")) return;
try {
await apiCall(endpoint, 'POST', { message });
showToast('Broadcast sent successfully', 'success');
document.getElementById(inputId).value = '';
} catch(e) {}
}
async function restartServer() {
if(!confirm("Are you sure you want to restart the background service? This drops all connections.")) return;
try {
const res = await apiCall('/dev/restart', 'POST');
showToast(res.status === 'restarting' ? 'Restart command issued' : 'Restarted', 'success');
} catch(e) {}
}
// Initialize on load
window.onload = () => {
loadStats();
};
</script>
</body>
</html>

178
templates/index.html Normal file
View file

@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Это днище интернета 2</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div id="app">
<!-- Auth Screen -->
<div id="auth-screen" class="auth-container">
<div class="auth-box">
<h1>Это днище интернета 2</h1>
<div id="auth-form">
<input type="text" id="username-input" placeholder="Username" class="auth-input">
<input type="password" id="password-input" placeholder="Password" class="auth-input">
<button id="login-btn" class="auth-btn">Login</button>
<button id="register-btn" class="auth-btn register-btn">Register</button>
<div id="auth-message" class="auth-message"></div>
</div>
</div>
</div>
<!-- Chat Screen -->
<div id="chat-screen" class="chat-container" style="display: none;">
<!-- Sidebar Overlay for Mobile -->
<div id="sidebar-overlay" class="sidebar-overlay"></div>
<!-- Sidebar Toggle Button (Mobile) -->
<button id="sidebar-toggle" class="sidebar-toggle"></button>
<!-- Servers column -->
<div id="servers-bar" class="servers-bar">
<!-- server icons will be injected here -->
<!-- <button id="create-server-btn" class="create-server-btn">+</button> -->
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="server-header">
<div class="server-name">Это днище</div>
<button id="logout-btn" class="logout-btn"></button>
</div>
<div class="sidebar-tabs">
<button class="tab-btn active" data-tab="chat">💬</button>
<button class="tab-btn" data-tab="friends">👥</button>
</div>
<!-- Chat Tab -->
<div id="chat-tab" class="tab-content active">
<div id="channels-list" class="channels">
<!-- channels will go here -->
</div>
<div class="dm-header">Friends</div>
<div id="dm-list" class="dm-list"></div>
</div>
<!-- Friends Tab -->
<div id="friends-tab" class="tab-content">
<div class="friends-section">
<div class="section-header">
<span>Friend Requests</span>
<button id="add-friend-btn" class="add-friend-btn">+</button>
</div>
<div id="friend-requests-list" class="friend-requests-list"></div>
</div>
<div class="friends-section">
<div class="section-header">My Friends</div>
<div id="friends-list" class="friends-list"></div>
</div>
</div>
</div>
<!-- Main Chat Area -->
<div class="main-content">
<!-- Header -->
<div class="chat-header">
<h2 id="header-title">Глобальный чат</h2>
<div id="current-user" class="current-user"></div>
<button id="profile-btn" class="profile-btn">⚙️</button>
</div>
<!-- Messages -->
<div id="messages-container" class="messages-container"></div>
<!-- Selected Media Preview -->
<div id="file-preview" class="file-preview" style="display: none;">
<span id="file-preview-text" class="file-preview-text"></span>
<button id="file-preview-clear" class="file-preview-clear"></button>
</div>
<!-- Reply Preview -->
<div id="reply-preview" class="reply-preview" style="display: none;">
<div class="reply-preview-content">
<span class="reply-preview-label">Replying to <span id="reply-username"></span></span>
<span id="reply-preview-text" class="reply-preview-text"></span>
</div>
<button id="reply-cancel-btn" class="reply-cancel-btn"></button>
</div>
<!-- Message Input -->
<div class="message-input-area">
<input type="file" id="file-input" class="file-input-hidden" />
<button id="attach-btn" class="attach-btn" title="Attach a file">📎</button>
<textarea id="message-input" placeholder="Write a message..." class="message-input"></textarea>
<button id="send-btn" class="send-btn">Send</button>
</div>
</div>
<!-- Add Friend Modal -->
<div id="add-friend-modal" class="modal">
<div class="modal-content">
<span class="close-modal">&times;</span>
<h2>Add Friend</h2>
<div class="modal-form">
<input type="text" id="friend-username-input" placeholder="Enter username..." class="friend-input">
<button id="modal-add-btn" class="modal-submit-btn">Add Friend</button>
<div id="modal-error" class="modal-error"></div>
</div>
</div>
</div>
<!-- Profile Modal -->
<div id="profile-modal" class="modal">
<div class="modal-content">
<span class="profile-close close-modal">&times;</span>
<h2>Edit Profile</h2>
<div class="modal-form">
<label>Avatar (max 520x520)</label>
<input type="file" id="profile-avatar-input" accept="image/*" class="friend-input">
<label>Pronouns</label>
<input type="text" id="profile-pronouns-input" class="friend-input" placeholder="e.g. they/them">
<label>Description</label>
<textarea id="profile-desc-input" class="friend-input" placeholder="About you..."></textarea>
<button id="profile-save-btn" class="modal-submit-btn">Save</button>
</div>
</div>
</div>
<!-- User Profile Preview Modal (Discord-like) -->
<div id="user-profile-modal" class="modal user-profile-modal">
<div class="user-profile-content">
<div class="user-profile-header">
<div class="user-profile-banner"></div>
<span class="user-profile-close close-modal">&times;</span>
</div>
<div class="user-profile-body">
<div class="user-profile-avatar-container">
<img id="user-profile-avatar" class="user-profile-avatar" src="" alt="avatar">
</div>
<div class="user-profile-info">
<h3 id="user-profile-username" class="user-profile-username"></h3>
<span id="user-profile-pronouns" class="user-profile-pronouns"></span>
</div>
<div class="user-profile-description">
<span id="user-profile-description" class="user-profile-description"></span>
</div>
<button id="user-profile-add-friend-btn" class="user-profile-add-friend-btn">Add Friend</button>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/variables.js"></script>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<script src="/static/js/auth.js"></script>
<script src="/static/js/servers.js"></script>
<script src="/static/js/friends.js"></script>
<script src="/static/js/chat.js"></script>
<script src="/static/js/profile.js"></script>
<script src="/static/js/ui.js"></script>
</body>
</html>