API аккаунтов на чистой стандартной библиотеке Python (server/splitapi): - регистрация по почте: пароль задаётся по ссылке из письма, аккаунт создаётся только после подтверждения - вход, выход, сброс пароля по почте (токены одноразовые, в базе только SHA-256), удаление аккаунта - пароли scrypt, лимиты попыток по паре «адрес + IP», IP и аккаунту, защита от CSRF - хранение прогресса пользователя с оптимистичной блокировкой версии - SMTP через переменные окружения; без SMTP на публичном адресе API не стартует Клиент: public/auth.js (вход, регистрация, сброс, синхронизация), public/sync.js (чистое слияние прогресса). Без API сайт работает как раньше. Развёртывание: Dockerfile.api, сервис splitapi в docker-compose, проксирование /api/ в nginx с ограничением частоты, deploy/install-api-debian.sh (systemd), .env.example. Тесты: 216 для бэкенда (unittest, покрытие около 97%), 16 для клиентского слияния. Документация: раздел «Аккаунты и почта» в docs/DEPLOY.md, раздел 9 в docs/ИНСТРУКЦИЯ.md.
70 lines
2.7 KiB
Python
70 lines
2.7 KiB
Python
"""Запуск: python -m splitapi (настройки из переменных окружения, см. docs/DEPLOY.md)."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import threading
|
|
import urllib.request
|
|
|
|
from . import security
|
|
from .config import ConfigError, from_env, is_local_host
|
|
from .http_api import make_server
|
|
from .mailer import AsyncMailer, make_mailer
|
|
from .ratelimit import RateLimiter
|
|
from .service import Service
|
|
from .store import Store
|
|
|
|
log = logging.getLogger("splitapi")
|
|
|
|
|
|
def healthcheck() -> int:
|
|
"""Для HEALTHCHECK контейнера: в образе нет curl, поэтому проверяем сами."""
|
|
port = os.environ.get("PORT") or "8000"
|
|
try:
|
|
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=3) as response:
|
|
return 0 if response.status == 200 else 1
|
|
except OSError:
|
|
return 1
|
|
|
|
|
|
def main() -> int:
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
if "--healthcheck" in sys.argv:
|
|
return healthcheck()
|
|
try:
|
|
cfg = from_env(os.environ)
|
|
except ConfigError as exc:
|
|
log.critical("Неверные настройки: %s", exc)
|
|
return 2
|
|
|
|
if cfg.mail_backend == "console":
|
|
log.warning("SMTP не настроен: письма (со ссылками подтверждения и сброса) будут печататься в этот лог. Только для разработки!")
|
|
elif cfg.smtp_security == "none" and not is_local_host(cfg.smtp_host):
|
|
log.warning("SMTP_SECURITY=none: письма и пароль SMTP уходят на %s без шифрования. Используйте starttls или ssl.", cfg.smtp_host)
|
|
security.verify_dummy("прогрев") # первый вход с несуществующей почтой не должен выдавать себя лишней задержкой
|
|
mailer = make_mailer(cfg)
|
|
service = Service(cfg, Store(cfg.db_path), mailer, RateLimiter())
|
|
server = make_server(cfg, service)
|
|
|
|
def stop(signum: int, _frame: object) -> None:
|
|
log.info("получен сигнал %s, останавливаюсь", signum)
|
|
threading.Thread(target=server.shutdown, daemon=True).start()
|
|
|
|
signal.signal(signal.SIGTERM, stop)
|
|
signal.signal(signal.SIGINT, stop)
|
|
log.info("API слушает %s:%s, сайт: %s", cfg.bind, cfg.port, cfg.public_url)
|
|
try:
|
|
server.serve_forever()
|
|
finally:
|
|
server.server_close()
|
|
if isinstance(mailer, AsyncMailer):
|
|
mailer.flush(timeout=5)
|
|
mailer.close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|