import json import logging import os import threading from flask import Flask, Response, jsonify, render_template, request, stream_with_context, abort import hashlib import hmac import json as _json from functools import wraps import config import database import events from intradesk import reference from intradesk import api as intradesk_api from intradesk.handler import handle_webhook log = logging.getLogger(__name__) app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), "templates")) # ── Авторизация ──────────────────────────────────────────────────────────────── def check_basic_auth(username, password): """Проверяет Basic Auth. Логин фиксированный 'admin'.""" if not config.DASHBOARD_PASSWORD: return True # Пароль не задан — всё открыто return username == "admin" and password == config.DASHBOARD_PASSWORD def require_basic_auth(f): """Декоратор: требует Basic Auth если DASHBOARD_PASSWORD задан.""" @wraps(f) def decorated(*args, **kwargs): if not config.DASHBOARD_PASSWORD: return f(*args, **kwargs) auth = request.authorization if not auth or not check_basic_auth(auth.username, auth.password): return Response( "Требуется авторизация", 401, {"WWW-Authenticate": 'Basic realm="IntradBot Dashboard"'}, ) return f(*args, **kwargs) return decorated def verify_task_token(task_number: str, exp: str, sig: str) -> bool: """Проверяет HMAC-подпись токена из URL Mini App.""" if not config.DASHBOARD_PASSWORD: return True # Защита отключена if not exp or not sig: return False try: import time as _time if int(exp) < int(_time.time()): log.warning(f"Token expired for task {task_number}") return False msg = f"{task_number}:{exp}".encode() expected = hmac.new(config.MINIAPP_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:16] return hmac.compare_digest(expected, sig) except Exception as e: log.warning(f"Token verification error: {e}") return False def require_task_token(f): """Декоратор: проверяет подписанный токен из URL для API Mini App.""" @wraps(f) def decorated(*args, **kwargs): if not config.DASHBOARD_PASSWORD: return f(*args, **kwargs) # Берём номер задачи из любого параметра запроса task = (request.args.get("number") or request.args.get("task_id") or kwargs.get("file_id", "")) exp = request.args.get("exp", "") sig = request.args.get("sig", "") if not verify_task_token(str(task), exp, sig): return jsonify({"error": "Unauthorized"}), 401 return f(*args, **kwargs) return decorated # ── Webhook от Intradesk ─────────────────────────────────────────────────────── @app.route("/webhook", methods=["POST"]) def webhook(): if config.WEBHOOK_SECRET: secret = request.headers.get("X-Webhook-Secret") or request.args.get("secret", "") if secret != config.WEBHOOK_SECRET: log.warning("Webhook: unauthorized request") return jsonify({"error": "Unauthorized"}), 401 data = request.get_json(silent=True) if not data: return jsonify({"error": "Invalid JSON"}), 400 threading.Thread(target=handle_webhook, args=(data,), daemon=True).start() return jsonify({"ok": True}), 200 @app.route("/health") def health(): return jsonify({"status": "ok"}), 200 # ── Дашборд ──────────────────────────────────────────────────────────────────── @app.route("/") @require_basic_auth def dashboard(): return render_template("dashboard.html") @app.route("/miniapp") def miniapp(): return render_template("miniapp.html") @app.route("/stream") @require_basic_auth def stream(): q = events.subscribe() def generate(): try: while True: try: data = q.get(timeout=25) yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n" except Exception: yield 'data: {"type":"ping"}\n\n' finally: events.unsubscribe(q) return Response( stream_with_context(generate()), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) # ── API заявок ───────────────────────────────────────────────────────────────── @app.route("/api/tasks/search") #@require_task_token def api_tasks_search(): try: number = int(request.args.get("number", 0)) task = intradesk_api.get_task(number) if task is None: return jsonify({"error": "Заявка не найдена"}), 404 return jsonify(task) except Exception as e: return jsonify({"error": str(e)}), 400 @app.route("/api/tasks/active") #@require_task_token def api_tasks_active(): filter_expr = request.args.get( "filter", config.ACTIVE_TASKS_FILTER ) tasks = intradesk_api.get_tasks(filter_expr, top=50) return jsonify(tasks) @app.route("/api/task/lifetime") #@require_task_token def api_task_lifetime(): try: task_id = int(request.args.get("task_id", 0)) top = int(request.args.get("top", 20)) data = intradesk_api.get_task_lifetime(task_id, top=top) return jsonify(data) except Exception as e: return jsonify({"error": str(e)}), 400 # ── Справочники ──────────────────────────────────────────────────────────────── @app.route("/api/ref/") def api_ref(entity): items = database.get_all_ref(entity) if not items: fallback = {"statuses": reference.STATUSES, "priorities": reference.PRIORITIES} d = fallback.get(entity, {}) items = [{"id": k, "name": v} for k, v in d.items()] return jsonify(items) # ── Изменение заявки ─────────────────────────────────────────────────────────── @app.route("/api/task/preview", methods=["POST"]) def api_task_preview(): data = request.get_json() or {} number = data.get("number") blocks = data.get("blocks", {}) body = {"number": number, "blocks": blocks, "Channel": "api"} return jsonify({ "method": "PUT", "url": "https://apigw.intradesk.ru/changes/v3/tasks/?ApiKey=***", "body": body, }) @app.route("/api/task/execute", methods=["POST"]) def api_task_execute(): data = request.get_json() or {} number = data.get("number") blocks = data.get("blocks", {}) if not number: return jsonify({"success": False, "error": "Не указан номер заявки"}), 400 success = intradesk_api.update_task(int(number), blocks) return jsonify({"success": success}) # ── Прокси для файлов Intradesk (скрывает API ключ) ─────────────────────────── @app.route("/api/files/") def api_file(file_id): """Проксирует файл из Intradesk, подставляя API ключ на сервере.""" import requests as req thumb = request.args.get("thumb") == "1" url = f"https://apigw.intradesk.ru/files/api/tasks/files/{file_id}" if thumb: url += "/thumbnail" try: r = req.get(url, params={"ApiKey": config.API_KEY}, timeout=30, stream=True) r.raise_for_status() content_type = r.headers.get("Content-Type", "application/octet-stream") return Response(r.content, content_type=content_type) except Exception as e: log.error(f"File proxy error for {file_id}: {e}") return jsonify({"error": "Файл недоступен"}), 502 def create_app(): """Фабрика приложения — используется Gunicorn и тестами.""" return app def start_server(): """Запуск через встроенный Flask — только для локальной разработки.""" ssl_cert = os.path.join("/app/ssl", "cert.pem") ssl_key = os.path.join("/app/ssl", "key.pem") if os.path.exists(ssl_cert) and os.path.exists(ssl_key): log.info(f"Web server → https://0.0.0.0:{config.PORT} (SSL, dev mode)") app.run(host="0.0.0.0", port=config.PORT, ssl_context=(ssl_cert, ssl_key), use_reloader=False) else: log.info(f"Web server → http://0.0.0.0:{config.PORT} (dev mode, no SSL)") app.run(host="0.0.0.0", port=config.PORT, use_reloader=False)