diff --git a/app/config.py b/app/config.py index b79df52..63e7be4 100644 --- a/app/config.py +++ b/app/config.py @@ -37,3 +37,11 @@ SERVICE_ICONS_DIR = Path("static/service-icons") TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") TELEGRAM_API_URL = os.getenv("TELEGRAM_API_URL", "https://api.telegram.org/bot") + +SMTP_HOST = os.getenv("SMTP_HOST", "mail.hosting.reg.ru") +SMTP_PORT = int(os.getenv("SMTP_PORT", "465")) +SMTP_USERNAME = os.getenv("SMTP_USERNAME", "") +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", "stand@4mont.ru") +SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "\u0418\u043d\u0444\u0440\u0430\u0441\u0442\u0443\u043a\u0442\u0443\u0440\u043d\u044b\u0439 \u043f\u043e\u043b\u0438\u0433\u043e\u043d MONT") +PORTAL_URL = os.getenv("PORTAL_URL", "https://stend.4mont.ru") diff --git a/app/main.py b/app/main.py index 4a6bc44..a66fa47 100644 --- a/app/main.py +++ b/app/main.py @@ -27,11 +27,13 @@ from config import ( MAX_ACTIVE_SERVICES_PER_USER, PUBLIC_HOST, SESSION_IDLE_SECONDS, WEB_POOL_BUFFER, WEB_POOL_SIZE, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_API_URL, + SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, + SMTP_FROM_EMAIL, SMTP_FROM_NAME, PORTAL_URL, ) from database import get_db from models import ( AuditLog, Category, RdpSlot, Service, ServiceCategory, ServiceType, - SessionModel, SessionStatus, User, UserServiceAccess, + PendingAccessRequest, SessionModel, SessionStatus, User, UserServiceAccess, ) from utils import ( audit, ensure_icons_dir, format_service_comment, log_event, normalize_web_target, @@ -90,6 +92,56 @@ def _get_geo(ip: str) -> str: pass return "" + +import secrets as _secrets +import string as _string +import smtplib as _smtplib +import ssl as _ssl +import json as _json2 +from email.mime.multipart import MIMEMultipart as _MIMEMultipart +from email.mime.text import MIMEText as _MIMEText + +def _generate_password(length: int = 10) -> str: + alphabet = _string.ascii_letters + _string.digits + while True: + pwd = ''.join(_secrets.choice(alphabet) for _ in range(length)) + if (any(c.isupper() for c in pwd) and any(c.islower() for c in pwd) + and any(c.isdigit() for c in pwd)): + return pwd + +def _send_email(to: str, subject: str, html_body: str) -> None: + msg = _MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>" + msg["To"] = to + msg.attach(_MIMEText(html_body, "html", "utf-8")) + ctx = _ssl.create_default_context() + with _smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ctx) as srv: + srv.login(SMTP_USERNAME, SMTP_PASSWORD) + srv.sendmail(SMTP_FROM_EMAIL, to, msg.as_string()) + +def _tg_api(method: str, payload: dict) -> dict: + import urllib.request as _ur + import json as _j + url = f"{TELEGRAM_API_URL}{TELEGRAM_BOT_TOKEN}/{method}" + data = _j.dumps(payload).encode() + req = _ur.Request(url, data=data, headers={"Content-Type": "application/json"}) + with _ur.urlopen(req, timeout=10) as r: + return _j.loads(r.read()) + +def _make_approval_keyboard(req_id: str) -> dict: + return { + "inline_keyboard": [ + [ + {"text": "7 дней", "callback_data": f"a7_{req_id}"}, + {"text": "14 дней", "callback_data": f"a14_{req_id}"}, + {"text": "30 дней", "callback_data": f"a30_{req_id}"}, + {"text": "90 дней", "callback_data": f"a90_{req_id}"}, + ], + [{"text": "Отказать", "callback_data": f"r_{req_id}"}], + ] + } + app = FastAPI(title="MONT - инфрастуктурный полигон") app.mount("/static", StaticFiles(directory="static"), name="static") @@ -443,6 +495,210 @@ def privacy_page(): return RedirectResponse(url="https://www.mont.ru/ru-ru/privacy", status_code=301) + +@app.post("/api/telegram-webhook", include_in_schema=False) +async def telegram_webhook(request: Request, db: Session = Depends(get_db)): + import json as _jw + import datetime as _dt2 + try: + data = await request.json() + except Exception: + return {"ok": True} + + cq = data.get("callback_query") + if not cq: + return {"ok": True} + + cq_id = cq["id"] + cb_data = cq.get("data", "") + chat_id = cq["message"]["chat"]["id"] + msg_id = cq["message"]["message_id"] + + try: + _tg_api("answerCallbackQuery", {"callback_query_id": cq_id}) + except Exception: + pass + + # parse callback_data: a7_ID, a14_ID, a30_ID, a90_ID, r_ID + import re as _rew + approve_match = _rew.match(r'^a(\d+)_(.+)$', cb_data) + reject_match = _rew.match(r'^r_(.+)$', cb_data) + + if not approve_match and not reject_match: + return {"ok": True} + + req_id = approve_match.group(2) if approve_match else reject_match.group(1) + pending = db.get(PendingAccessRequest, req_id) + if not pending: + try: + _tg_api("editMessageText", { + "chat_id": chat_id, "message_id": msg_id, + "text": "Запрос не найден (возможно уже обработан).", + }) + except Exception: + pass + return {"ok": True} + + if pending.status != "pending": + try: + _tg_api("editMessageText", { + "chat_id": chat_id, "message_id": msg_id, + "text": f"Запрос уже обработан: {pending.status}.", + }) + except Exception: + pass + return {"ok": True} + + products = _jw.loads(pending.products_json or "[]") + portal_url = PORTAL_URL + + if approve_match: + days = int(approve_match.group(1)) + password = _generate_password() + username = pending.email + + # ensure username unique + if db.scalar(select(User).where(User.username == username)): + username = pending.email.split("@")[0] + "_" + _secrets.token_hex(3) + + expires = _dt2.datetime.now(_dt2.timezone.utc) + _dt2.timedelta(days=days) + parts = pending.name.strip().split(None, 1) + new_user = User( + username=username, + password_hash=hash_password(password), + expires_at=expires, + active=True, + is_admin=False, + first_name=parts[0] if parts else "", + last_name=parts[1] if len(parts) > 1 else "", + ) + db.add(new_user) + db.flush() + + # assign requested services + if products: + from sqlalchemy import func as _func + matched = db.scalars( + select(Service).where( + func.lower(Service.name).in_([p.lower() for p in products]), + Service.active == True, + ) + ).all() + for svc in matched: + db.add(UserServiceAccess(user_id=new_user.id, service_id=svc.id)) + + db.commit() + + # send approval email + products_html = "" + if products: + items = "".join(f"
  • {p}
  • " for p in products) + products_html = f"

    Предоставлен доступ к продуктам:

    " + + html_email = f""" + + +
    + + + + +
    + MONT
    +

    Доступ к Инфраструктурному полигону MONT

    +

    Ваш запрос одобрен

    +
    +
    +

    Здравствуйте, {pending.name}!
    + Вам предоставлен доступ к полигону на {days} {'день' if days==1 else 'дня' if days<5 else 'дней'}.

    + + + + + + + + + +
    Адрес портала{portal_url}
    Логин{username}
    Пароль{password}
    Доступ до{expires.strftime('%d.%m.%Y')}
    + {products_html} + +
    + Если у вас возникли вопросы, свяжитесь с вашим менеджером MONT или напишите на mont@mont.ru +
    +""" + + pending.status = "approved" + db.commit() + + try: + _send_email(pending.email, "Доступ к Инфраструктурному полигону MONT", html_email) + email_status = "Email отправлен" + except Exception as ex: + log_event("email_send_error", error=str(ex)) + email_status = f"Ошибка отправки email: {ex}" + + try: + _tg_api("editMessageText", { + "chat_id": chat_id, "message_id": msg_id, + "text": ( + f"Одобрено на {days} дней\n" + f"Логин: `{username}`\n" + f"Пароль: `{password}`\n" + f"{email_status}" + ), + "parse_mode": "Markdown", + }) + except Exception: + pass + + elif reject_match: + manager_contact = pending.manager if pending.manager else "менеджера MONT" + html_email = f""" + + +
    + + + + +
    + MONT
    +

    Запрос на доступ к полигону MONT

    +
    +
    +

    Здравствуйте, {pending.name}!

    + К сожалению, на данный момент мы не можем предоставить доступ к полигону.

    +

    + Для уточнения деталей, пожалуйста, свяжитесь с {manager_contact}.
    + Если вы не знаете, кто ваш менеджер, напишите нам на mont@mont.ru — мы поможем.

    +
    + С уважением, команда MONT +
    +""" + + pending.status = "rejected" + db.commit() + + try: + _send_email(pending.email, "Запрос на доступ к полигону MONT", html_email) + email_status = "Email отправлен" + except Exception as ex: + log_event("email_send_error", error=str(ex)) + email_status = f"Ошибка отправки email: {ex}" + + try: + _tg_api("editMessageText", { + "chat_id": chat_id, "message_id": msg_id, + "text": f"Отклонено. {email_status}", + }) + except Exception: + pass + + return {"ok": True} + @app.get("/robots.txt", include_in_schema=False) def robots_txt(): from fastapi.responses import FileResponse @@ -534,19 +790,26 @@ async def request_access(request: Request, db: Session = Depends(get_db)): log_event("telegram_not_configured") return {"ok": True} + # save pending request + import json as _j2 + req_id = _secrets.token_urlsafe(8)[:12] + pending = PendingAccessRequest( + id=req_id, name=name, company=company, email=email, + phone=phone, manager=manager, + products_json=_j2.dumps(products, ensure_ascii=False), + ) + db.add(pending) + db.commit() + try: - payload = _json.dumps({ + _tg_api("sendMessage", { "chat_id": TELEGRAM_CHAT_ID, "text": text, "parse_mode": "Markdown", - }).encode() - url = f"{TELEGRAM_API_URL}{TELEGRAM_BOT_TOKEN}/sendMessage" - req = _urllib_request.Request(url, data=payload, headers={"Content-Type": "application/json"}) - with _urllib_request.urlopen(req, timeout=10) as resp: - resp.read() + "reply_markup": _make_approval_keyboard(req_id), + }) except Exception as e: log_event("telegram_send_error", error=str(e)) - raise HTTPException(status_code=502, detail="Ошибка отправки запроса") return {"ok": True} diff --git a/app/models.py b/app/models.py index f7bc7f3..dac7a96 100644 --- a/app/models.py +++ b/app/models.py @@ -115,3 +115,17 @@ class AuditLog(Base): action: Mapped[str] = mapped_column(String(128), index=True) details: Mapped[str] = mapped_column(Text) created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True) + + +class PendingAccessRequest(Base): + __tablename__ = "pending_access_requests" + + id: Mapped[str] = mapped_column(String(12), primary_key=True) + name: Mapped[str] = mapped_column(String(256)) + company: Mapped[str] = mapped_column(String(256)) + email: Mapped[str] = mapped_column(String(256)) + phone: Mapped[str] = mapped_column(String(64)) + manager: Mapped[str] = mapped_column(String(256), default="") + products_json: Mapped[str] = mapped_column(Text, default="[]") + status: Mapped[str] = mapped_column(String(16), default="pending") + created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc)) diff --git a/docker-compose.yml b/docker-compose.yml index 7b7e918..de83024 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,13 @@ services: TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} TELEGRAM_API_URL: ${TELEGRAM_API_URL:-https://api.telegram.org/bot} + SMTP_HOST: ${SMTP_HOST:-mail.hosting.reg.ru} + SMTP_PORT: ${SMTP_PORT:-465} + SMTP_USERNAME: ${SMTP_USERNAME:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-} + SMTP_FROM_NAME: ${SMTP_FROM_NAME:-} + PORTAL_URL: ${PORTAL_URL:-https://stend.4mont.ru} depends_on: - db volumes: @@ -113,6 +120,13 @@ services: TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} TELEGRAM_API_URL: ${TELEGRAM_API_URL:-https://api.telegram.org/bot} + SMTP_HOST: ${SMTP_HOST:-mail.hosting.reg.ru} + SMTP_PORT: ${SMTP_PORT:-465} + SMTP_USERNAME: ${SMTP_USERNAME:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-} + SMTP_FROM_NAME: ${SMTP_FROM_NAME:-} + PORTAL_URL: ${PORTAL_URL:-https://stend.4mont.ru} depends_on: - db volumes: