uploaded all code
This commit is contained in:
parent
19fdfc7a1f
commit
ffe5a796a0
94
app.py
Normal file
94
app.py
Normal 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
1
blueprints/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Blueprints package initialization."""
|
||||
116
blueprints/admin.py
Normal file
116
blueprints/admin.py
Normal 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
61
blueprints/auth.py
Normal 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
174
blueprints/chat.py
Normal 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
131
blueprints/dev.py
Normal 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
66
blueprints/files.py
Normal 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
172
blueprints/users.py
Normal 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
111
database.py
Normal 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
99
db.py
Normal 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
95
decorators.py
Normal 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
6
requirements.txt
Normal 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
19
static/custom_502.html
Normal 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
183
static/js/auth.js
Normal 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
630
static/js/chat.js
Normal 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
205
static/js/friends.js
Normal 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
360
static/js/profile.js
Normal 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
145
static/js/servers.js
Normal 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
131
static/js/ui.js
Normal 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
69
static/js/variables.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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
1207
static/style.css
Normal file
File diff suppressed because it is too large
Load diff
315
templates/admin.html
Normal file
315
templates/admin.html
Normal 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
660
templates/dev.html
Normal 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
178
templates/index.html
Normal 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">×</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">×</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">×</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>
|
||||
Loading…
Reference in a new issue