Загрузка данных
import base64
import hashlib
import hmac
import json
import os
import re
import secrets
import sqlite3
import string
import threading
import time
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import quote, urlparse
ROOT = Path(__file__).parent
DB = Path(os.environ.get("CHAT_DB", ROOT / "data.sqlite3"))
TTL = 3 * 60 * 60
LIMIT = 25 * 1024
LOCK = threading.RLock()
def connect():
connection = sqlite3.connect(DB)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys=ON")
return connection
def init():
with connect() as connection:
connection.executescript("""
PRAGMA journal_mode=WAL;
PRAGMA secure_delete=ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
login TEXT UNIQUE,
password TEXT,
salt TEXT,
name TEXT,
bio TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user INTEGER REFERENCES users(id),
expires REAL
);
CREATE TABLE IF NOT EXISTS rooms (
code TEXT PRIMARY KEY,
title TEXT,
owner INTEGER REFERENCES users(id),
active REAL
);
CREATE TABLE IF NOT EXISTS members (
room TEXT REFERENCES rooms(code) ON DELETE CASCADE,
user INTEGER REFERENCES users(id),
PRIMARY KEY(room, user)
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room TEXT REFERENCES rooms(code) ON DELETE CASCADE,
user INTEGER REFERENCES users(id),
text TEXT,
created REAL,
filename TEXT,
data BLOB
);
""")
def cleanup():
with LOCK, connect() as connection:
connection.execute("PRAGMA secure_delete=ON")
connection.execute(
"DELETE FROM rooms WHERE active <= ?",
(time.time() - TTL,)
)
connection.execute(
"DELETE FROM sessions WHERE expires <= ?",
(time.time(),)
)
connection.commit()
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
def janitor():
while True:
try:
cleanup()
except Exception:
import traceback
traceback.print_exc()
time.sleep(30)
def password(value, salt):
return hashlib.scrypt(
value.encode(),
salt=bytes.fromhex(salt),
n=16384,
r=8,
p=1
).hex()
class Error(Exception):
def __init__(self, status, message):
super().__init__(message)
self.status = status
self.message = message
class Handler(BaseHTTPRequestHandler):
def reply(self, data, status=200, headers=None):
raw = json.dumps(
data,
ensure_ascii=False
).encode()
self.send_bytes(
raw,
"application/json; charset=utf-8",
status,
headers
)
def send_bytes(self, raw, content_type, status=200, headers=None):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(raw)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"object-src 'none'; "
"base-uri 'none'; "
"frame-ancestors 'none'"
)
for key, value in (headers or {}).items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(raw)
def user(self, connection):
cookie = SimpleCookie()
try:
cookie.load(self.headers.get("Cookie", ""))
except Exception:
raise Error(401, "Войдите в профиль")
token = cookie.get("session")
user = connection.execute(
"""
SELECT users.*
FROM users
JOIN sessions ON users.id = sessions.user
WHERE token = ? AND expires > ?
""",
(token.value if token else "", time.time())
).fetchone()
if not user:
raise Error(401, "Войдите в профиль")
return user
def room(self, connection, code, user_id):
room = connection.execute(
"SELECT * FROM rooms WHERE code = ?",
(code,)
).fetchone()
if not room:
raise Error(404, "Комната удалена или не существует")
member = connection.execute(
"SELECT 1 FROM members WHERE room = ? AND user = ?",
(code, user_id)
).fetchone()
if not member:
raise Error(403, "Сначала войдите по приглашению")
return room
def do_GET(self):
self.handle_request(False)
def do_POST(self):
self.handle_request(True)
def handle_request(self, post):
try:
path = urlparse(self.path).path
is_static = (
path in ("/", "/app.js", "/style.css")
or re.fullmatch(r"/r/[a-z]{3}", path)
)
if not post and is_static:
name = {
"/app.js": "app.js",
"/style.css": "style.css"
}.get(path, "index.html")
content_type = {
"app.js": "text/javascript; charset=utf-8",
"style.css": "text/css; charset=utf-8",
"index.html": "text/html; charset=utf-8"
}[name]
self.send_bytes(
(ROOT / "static" / name).read_bytes(),
content_type
)
return
data = {}
if post:
if self.headers.get("X-Chat-Request") != "1":
raise Error(403, "Недопустимый запрос")
size = int(self.headers.get("Content-Length", "0"))
if size < 0 or size > 50000:
raise Error(413, "Слишком большой запрос")
data = json.loads(self.rfile.read(size))
if not isinstance(data, dict):
raise Error(400, "Ожидается JSON")
cleanup()
with LOCK, connect() as connection:
if post and path == "/api/login":
login = str(data.get("login", "")).strip().lower()
pw = str(data.get("password", ""))
if (
not re.fullmatch(r"[a-z0-9_]{3,24}", login)
or not 8 <= len(pw) <= 128
):
raise Error(
400,
"Логин: 3-24 латинских символа, цифры или _. "
"Пароль: 8-128 символов."
)
user = connection.execute(
"SELECT * FROM users WHERE login = ?",
(login,)
).fetchone()
if user and not hmac.compare_digest(
user["password"],
password(pw, user["salt"])
):
raise Error(401, "Неверный логин или пароль")
if not user:
salt = secrets.token_hex(16)
user_id = connection.execute(
"""
INSERT INTO users(login, password, salt, name)
VALUES (?, ?, ?, ?)
""",
(login, password(pw, salt), salt, login)
).lastrowid
else:
user_id = user["id"]
token = secrets.token_urlsafe(32)
connection.execute(
"INSERT INTO sessions VALUES (?, ?, ?)",
(token, user_id, time.time() + 30 * 86400)
)
secure = (
"; Secure"
if os.environ.get("COOKIE_SECURE") == "1"
else ""
)
self.reply(
{"ok": True},
headers={
"Set-Cookie": (
f"session={token}; HttpOnly; SameSite=Lax; "
f"Path=/; Max-Age=2592000{secure}"
)
}
)
return
user = self.user(connection)
if path == "/api/me" and not post:
rooms = [
dict(row)
for row in connection.execute(
"""
SELECT code, title, active
FROM rooms
JOIN members ON code = room
WHERE user = ?
ORDER BY active DESC
""",
(user["id"],)
)
]
self.reply({
"id": user["id"],
"login": user["login"],
"name": user["name"],
"bio": user["bio"],
"rooms": rooms
})
return
if post and path == "/api/logout":
cookie = SimpleCookie(
self.headers.get("Cookie", "")
)
connection.execute(
"DELETE FROM sessions WHERE token = ?",
(cookie["session"].value,)
)
self.reply(
{"ok": True},
headers={
"Set-Cookie": (
"session=; HttpOnly; SameSite=Lax; "
"Path=/; Max-Age=0"
)
}
)
return
if post and path == "/api/profile":
name = str(data.get("name", "")).strip()
bio = str(data.get("bio", "")).strip()
if not 1 <= len(name) <= 40 or len(bio) > 200:
raise Error(
400,
"Имя: 1-40 символов, описание: до 200"
)
connection.execute(
"UPDATE users SET name = ?, bio = ? WHERE id = ?",
(name, bio, user["id"])
)
self.reply({"ok": True})
return
if post and path == "/api/rooms":
title = str(data.get("title", "")).strip()
if not 1 <= len(title) <= 60:
raise Error(400, "Название: 1-60 символов")
for _ in range(100):
code = "".join(
secrets.choice(string.ascii_lowercase)
for _ in range(3)
)
exists = connection.execute(
"SELECT 1 FROM rooms WHERE code = ?",
(code,)
).fetchone()
if not exists:
break
else:
raise Error(503, "Нет свободных кодов")
connection.execute(
"INSERT INTO rooms VALUES (?, ?, ?, ?)",
(code, title, user["id"], time.time())
)
connection.execute(
"INSERT INTO members VALUES (?, ?)",
(code, user["id"])
)
self.reply({"code": code})
return
match = re.fullmatch(
r"/api/rooms/([a-z]{3})(/join|/messages)?",
path
)
if match:
code, action = match.groups()
if post and action == "/join":
exists = connection.execute(
"SELECT 1 FROM rooms WHERE code = ?",
(code,)
).fetchone()
if not exists:
raise Error(
404,
"Комната удалена или не существует"
)
connection.execute(
"INSERT OR IGNORE INTO members VALUES (?, ?)",
(code, user["id"])
)
self.reply({"ok": True})
return
room = self.room(connection, code, user["id"])
if not post and not action:
messages = [
dict(row)
for row in connection.execute(
"""
SELECT
m.id,
m.user,
m.text,
m.created,
m.filename,
length(m.data) AS size,
u.name,
u.bio
FROM messages m
JOIN users u ON u.id = m.user
WHERE room = ?
ORDER BY m.id DESC
LIMIT 500
""",
(code,)
)
][::-1]
self.reply({
"code": code,
"title": room["title"],
"expires": room["active"] + TTL,
"messages": messages
})
return
if post and action == "/messages":
text = str(data.get("text", "")).strip()
filename = None
blob = None
if len(text) > 8000:
raise Error(
400,
"Сообщение: до 8000 символов"
)
if data.get("filename"):
filename = (
str(data["filename"])
.replace("\\", "/")
.split("/")[-1][:120]
)
try:
blob = base64.b64decode(
data.get("file", ""),
validate=True
)
except (ValueError, TypeError):
raise Error(400, "Повреждённый файл")
if len(blob) > LIMIT:
raise Error(
413,
"Файл больше 25 КБ (25 600 байт)"
)
if not text and blob is None:
raise Error(400, "Пустое сообщение")
now = time.time()
connection.execute(
"""
INSERT INTO messages(
room, user, text, created, filename, data
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
code,
user["id"],
text,
now,
filename,
blob
)
)
connection.execute(
"UPDATE rooms SET active = ? WHERE code = ?",
(now, code)
)
self.reply({"ok": True})
return
match = re.fullmatch(r"/api/files/(\d+)", path)
if not post and match:
file = connection.execute(
"""
SELECT *
FROM messages
WHERE id = ? AND data IS NOT NULL
""",
(match[1],)
).fetchone()
if not file:
raise Error(404, "Файл удалён")
self.room(connection, file["room"], user["id"])
self.send_bytes(
file["data"],
"application/octet-stream",
headers={
"Content-Disposition": (
"attachment; filename*=UTF-8''"
+ quote(file["filename"])
)
}
)
return
raise Error(404, "Не найдено")
except Error as error:
self.reply(
{"error": error.message},
error.status
)
except (ValueError, TypeError, KeyError):
self.reply(
{"error": "Некорректный запрос"},
400
)
except (BrokenPipeError, ConnectionResetError):
pass
except Exception:
import traceback
traceback.print_exc()
self.reply(
{"error": "Ошибка сервера"},
500
)
if __name__ == "__main__":
init()
cleanup()
threading.Thread(
target=janitor,
daemon=True
).start()
host = "0.0.0.0"
port = 26161
print(
f"Roomchat запущен на {host}:{port}",
flush=True
)
ThreadingHTTPServer(
(host, port),
Handler
).serve_forever()