Загрузка данных


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