cfd337a673
The admin password field is gone from both the create-user and
edit-user forms. Three flows now generate a password and mail it
instead of letting an admin type one:
- create_user(): password generated, welcome email sent
(login/password/portal link) right after the row commits
- new POST /api/admin/users/{id}/reset-password: generates a new
password, saves it, emails it - the only way a password changes now
- assign_rdp_slot(): sending a user their first grant on a pilot
(not every later tweak of an existing grant's expiry - guarded by
an is_new_grant check) sends a short "you have pilot access" email,
no credentials since the account already exists
If an email fails to send, the plaintext password comes back in the
API response as a one-time fallback so the admin isn't locked out of
handing it over by hand - the frontend only shows it then, never on
a successful send.
Added two small helpers (_build_light_email, _credentials_table) so
these three new templates share the light-theme card shell instead
of re-typing it; the existing access-approval emails keep their own
inline copies untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3214 lines
147 KiB
Python
3214 lines
147 KiB
Python
import datetime as dt
|
||
import logging
|
||
import os
|
||
import re
|
||
import secrets
|
||
import threading
|
||
import uuid
|
||
import time
|
||
import contextvars
|
||
from typing import Optional
|
||
|
||
from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile, status
|
||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.templating import Jinja2Templates
|
||
from markupsafe import Markup, escape
|
||
from sqlalchemy import delete, select, text, update
|
||
from sqlalchemy.orm import Session
|
||
from starlette.responses import HTMLResponse as _HR
|
||
|
||
import urllib.request as _urllib_request
|
||
import urllib.parse as _urllib_parse
|
||
import json as _json
|
||
from config import (
|
||
COOKIE_MAX_AGE, COOKIE_NAME, CSRF_COOKIE, GO_POOL_LOCK_TIMEOUT_SECONDS,
|
||
GO_USER_LOCK_TIMEOUT_SECONDS, LOG_LEVEL, LOG_SLOW_REQUEST_MS,
|
||
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,
|
||
ADMIN_NOTIFY_EMAIL, SIGNING_KEY,
|
||
)
|
||
from database import get_db
|
||
from models import (
|
||
AuditLog, Category, RdpSlot, Service, ServiceCategory, ServiceType,
|
||
PendingAccessRequest, SessionModel, SessionStatus, User, UserServiceAccess,
|
||
)
|
||
from utils import (
|
||
audit, ensure_icons_dir, format_service_comment, format_seo_description, log_event,
|
||
normalize_web_target, now_utc, parse_rdp_target, remove_icon_file, request_id_ctx,
|
||
set_service_categories, session_closed_reason, store_service_icon,
|
||
)
|
||
from auth import (
|
||
get_current_user, has_access, issue_auth_cookie, issue_csrf_cookie,
|
||
require_admin, require_user, user_is_valid, validate_csrf, verify_password, hash_password,
|
||
check_login_rate_limit,
|
||
record_login_failure,
|
||
record_login_success,
|
||
serializer,
|
||
)
|
||
from runtime import (
|
||
acquire_universal_slot, acquire_web_pool_slot, allocator_lock,
|
||
container_running, create_runtime_container, desired_pool_size,
|
||
dispatch_universal_target, dispatch_web_pool_target,
|
||
docker_client, ensure_universal_pool, ensure_warm_pool, ensure_web_pool,
|
||
find_active_session_for_service, find_active_session_for_user_service,
|
||
get_active_sessions_count, get_pool_detailed_status, get_pool_status_for_service,
|
||
get_universal_pool_status, get_web_pool_status, LockTimeoutError, open_warm_web_url,
|
||
_rdp_slot_container_name, route_ready, sanitize_client_resolution,
|
||
service_uses_universal_pool, session_redirect_url,
|
||
connect_rdp_slot, start_rdp_slot_container, stop_rdp_slot_container,
|
||
stop_runtime_container, terminate_active_slot_sessions,
|
||
terminate_session_record, wait_for_session_route,
|
||
)
|
||
from maintenance import on_startup
|
||
|
||
logging.basicConfig(
|
||
level=LOG_LEVEL,
|
||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||
)
|
||
logger = logging.getLogger("portal")
|
||
|
||
templates = Jinja2Templates(directory="templates")
|
||
|
||
def _get_real_ip(request) -> str:
|
||
"""Real client IP from X-Forwarded-For (Traefik trusts NPM via trustedIPs)."""
|
||
forwarded_for = request.headers.get("x-forwarded-for", "")
|
||
if forwarded_for:
|
||
return forwarded_for.split(",")[0].strip()
|
||
return request.client.host if request.client else "unknown"
|
||
|
||
|
||
def _get_geo(ip: str) -> str:
|
||
"""Lookup city/country for IP via ip-api.com. Returns formatted string or empty."""
|
||
try:
|
||
if ip in ("unknown", "127.0.0.1", "::1") or ip.startswith("10.") or ip.startswith("192.168."):
|
||
return ""
|
||
url = f"http://ip-api.com/json/{ip}?lang=ru&fields=status,country,regionName,city,query"
|
||
req = _urllib_request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||
with _urllib_request.urlopen(req, timeout=5) as resp:
|
||
data = _json.loads(resp.read())
|
||
if data.get("status") == "success":
|
||
parts = [data.get("country", ""), data.get("regionName", ""), data.get("city", "")]
|
||
return ", ".join(p for p in parts if p)
|
||
except Exception:
|
||
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
|
||
from email.mime.image import MIMEImage as _MIMEImage
|
||
from email.header import Header as _Header
|
||
from email.utils import formataddr as _formataddr
|
||
|
||
# MONT logo for HTML emails, attached inline (Content-ID) rather than
|
||
# embedded as a base64 data: URI in <img src>. Outlook's desktop/Word
|
||
# renderer does not support data: URIs for images at all (silently
|
||
# drops them), so a cid: reference to an attached image is the only
|
||
# way to get the logo to show up there.
|
||
_LOGO_EMAIL_PATH = os.path.join(os.path.dirname(__file__), "static", "logo-email.png")
|
||
try:
|
||
with open(_LOGO_EMAIL_PATH, "rb") as _f:
|
||
_LOGO_EMAIL_BYTES = _f.read()
|
||
except OSError:
|
||
_LOGO_EMAIL_BYTES = b""
|
||
|
||
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
|
||
|
||
_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
||
|
||
|
||
def _send_email(to: str, subject: str, html_body: str) -> None:
|
||
if not _EMAIL_RE.match(to or ""):
|
||
# Some accounts (older/manually-created ones) have a plain username
|
||
# instead of an email address as their login - PendingAccessRequest.email
|
||
# mirrors that username for the in-app "request more access" flow, so
|
||
# this is a normal, expected case here, not a real delivery failure.
|
||
# Fail fast with a clear message instead of letting smtplib open a
|
||
# connection just to have the SMTP server reject the recipient a few
|
||
# seconds later with a cryptic 501.
|
||
raise ValueError(f"'{to}' не похож на email-адрес - уведомление не отправлено")
|
||
# "related" wrapper so the inline logo (Content-ID) sits alongside the
|
||
# alternative/html part rather than being just another attachment -
|
||
# needed for cid: references in html_body to resolve in mail clients.
|
||
has_logo = _LOGO_EMAIL_BYTES and "cid:mont_logo" in html_body
|
||
msg = _MIMEMultipart("related") if has_logo else _MIMEMultipart("alternative")
|
||
msg["Subject"] = subject
|
||
msg["From"] = _formataddr((str(_Header(SMTP_FROM_NAME, "utf-8")), SMTP_FROM_EMAIL))
|
||
msg["To"] = to
|
||
if has_logo:
|
||
alt = _MIMEMultipart("alternative")
|
||
alt.attach(_MIMEText(html_body, "html", "utf-8"))
|
||
msg.attach(alt)
|
||
logo = _MIMEImage(_LOGO_EMAIL_BYTES, "png")
|
||
logo.add_header("Content-ID", "<mont_logo>")
|
||
logo.add_header("Content-Disposition", "inline", filename="logo.png")
|
||
msg.attach(logo)
|
||
else:
|
||
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 _build_light_email(title: str, subhead: str, body_html: str, cta_url: str | None = None, cta_label: str | None = None) -> str:
|
||
"""Shared light-theme card shell (logo, heading, body, optional button,
|
||
footer) for one-off transactional emails that don't need the full
|
||
credentials-table layout the access-approval templates have inline.
|
||
Those keep their own copies (changing one must not risk the others);
|
||
this is for newer, simpler notifications - welcome, password reset,
|
||
pilot-granted - so they don't each re-type the whole shell."""
|
||
cta_html = ""
|
||
if cta_url:
|
||
cta_html = (
|
||
f'<div style="margin:28px 0">'
|
||
f'<a href="{cta_url}" style="display:inline-block;padding:12px 28px;background:#1a5db5;color:#fff;'
|
||
f'text-decoration:none;border-radius:8px;font-size:15px;font-weight:600">{cta_label or "Войти в полигон"}</a>'
|
||
f'</div>'
|
||
)
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="ru"><head><meta charset="utf-8"/></head>
|
||
<body style="margin:0;padding:0;background:#eef1f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
|
||
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 20px">
|
||
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e3e8ee">
|
||
<tr><td style="padding:32px 36px 0">
|
||
<img src="cid:mont_logo" alt="MONT" width="160" height="46" style="display:block;margin-bottom:24px;border:0"/>
|
||
<h1 style="margin:0 0 8px;font-size:22px;color:#14233a">{title}</h1>
|
||
<p style="margin:0 0 24px;color:#7c8ba0;font-size:14px">{subhead}</p>
|
||
<hr style="border:none;border-top:1px solid #e7ebf0;margin:0 0 24px"/>
|
||
</td></tr>
|
||
<tr><td style="padding:0 36px">
|
||
{body_html}
|
||
{cta_html}
|
||
</td></tr>
|
||
<tr><td style="padding:20px 36px 28px;color:#8b98ab;font-size:12px;border-top:1px solid #e7ebf0;line-height:1.6">
|
||
Если у вас возникли вопросы, свяжитесь с вашим менеджером MONT или напишите на <a href="mailto:RGalyaviev@mont.ru" style="color:#1a5db5">RGalyaviev@mont.ru</a>
|
||
</td></tr>
|
||
</table></td></tr></table>
|
||
</body></html>"""
|
||
|
||
|
||
def _credentials_table(rows: list[tuple[str, str, bool]]) -> str:
|
||
"""rows: (label, value, monospace) - renders the same light-theme
|
||
label/value table used across access emails."""
|
||
trs = []
|
||
for i, (label, value, mono) in enumerate(rows):
|
||
border = "border-top:1px solid #e7ebf0;" if i > 0 else ""
|
||
font = "font-family:monospace;" if mono else ""
|
||
trs.append(
|
||
f'<tr><td style="color:#7c8ba0;font-size:13px;width:40%;{border}">{label}</td>'
|
||
f'<td style="{border}color:#14233a;font-size:14px;{font}">{value}</td></tr>'
|
||
)
|
||
return (
|
||
'<table width="100%" cellpadding="12" cellspacing="0" '
|
||
'style="background:#f7f9fb;border-radius:10px;border:1px solid #e7ebf0">'
|
||
+ "".join(trs) + "</table>"
|
||
)
|
||
|
||
|
||
def _send_welcome_email(user, password: str) -> str:
|
||
"""Sent once, when an admin manually creates a user on the Users tab -
|
||
the only way that person learns their login/password, since the admin
|
||
never sees the plaintext (create_user() generates it)."""
|
||
body = (
|
||
f'<p style="color:#45566e;font-size:15px;line-height:1.6;margin:0 0 20px">'
|
||
f'Здравствуйте{", <b>" + escape(user.first_name) + "</b>" if user.first_name else ""}!<br>'
|
||
f'Для вас создан аккаунт на Инфраструктурном полигоне MONT.</p>'
|
||
+ _credentials_table([
|
||
("Адрес портала", f'<a href="{PORTAL_URL}" style="color:#1a5db5">{PORTAL_URL}</a>', False),
|
||
("Логин", escape(user.username), True),
|
||
("Пароль", escape(password), True),
|
||
])
|
||
)
|
||
html = _build_light_email("Доступ к Инфраструктурному полигону MONT", "Для вас создан аккаунт", body, PORTAL_URL)
|
||
try:
|
||
_send_email(user.username, "Доступ к Инфраструктурному полигону MONT", html)
|
||
return "Email отправлен"
|
||
except Exception as ex:
|
||
log_event("email_send_error", error=str(ex), channel="welcome")
|
||
return f"Ошибка email: {ex}"
|
||
|
||
|
||
def _send_password_reset_email(user, password: str) -> str:
|
||
"""Sent when an admin resets a user's password from the Users tab -
|
||
same reasoning as the welcome email: the plaintext only ever exists
|
||
in this message, never in the admin UI."""
|
||
body = (
|
||
f'<p style="color:#45566e;font-size:15px;line-height:1.6;margin:0 0 20px">'
|
||
f'Здравствуйте{", <b>" + escape(user.first_name) + "</b>" if user.first_name else ""}!<br>'
|
||
f'Пароль от вашего аккаунта на полигоне MONT был сброшен администратором.</p>'
|
||
+ _credentials_table([
|
||
("Адрес портала", f'<a href="{PORTAL_URL}" style="color:#1a5db5">{PORTAL_URL}</a>', False),
|
||
("Логин", escape(user.username), True),
|
||
("Новый пароль", escape(password), True),
|
||
])
|
||
)
|
||
html = _build_light_email("Пароль обновлён", "Инфраструктурный полигон MONT", body, PORTAL_URL)
|
||
try:
|
||
_send_email(user.username, "Пароль обновлён — полигон MONT", html)
|
||
return "Email отправлен"
|
||
except Exception as ex:
|
||
log_event("email_send_error", error=str(ex), channel="password_reset")
|
||
return f"Ошибка email: {ex}"
|
||
|
||
|
||
def _send_pilot_granted_email(user, service, expires_at) -> str:
|
||
"""Sent once, the moment a pilot's RDP slot is first assigned to this
|
||
user (not on every later tweak of that same assignment - see the
|
||
"new grant" check in assign_rdp_slot()). No credentials here: this
|
||
user already has an account and password, this just tells them a new
|
||
product showed up."""
|
||
expiry_row = (
|
||
f'<p style="color:#7c8ba0;font-size:13px;margin:0 0 20px">Доступ действует до <b style="color:#14233a">{expires_at.strftime("%d.%m.%Y")}</b></p>'
|
||
if expires_at else ""
|
||
)
|
||
body = (
|
||
f'<p style="color:#45566e;font-size:15px;line-height:1.6;margin:0 0 16px">'
|
||
f'Здравствуйте{", <b>" + escape(user.first_name) + "</b>" if user.first_name else ""}!<br>'
|
||
f'Вам открыт доступ к пилотному проекту <b>{escape(service.name)}</b> на Инфраструктурном полигоне MONT.</p>'
|
||
+ expiry_row
|
||
)
|
||
html = _build_light_email(f"Доступ к пилоту: {escape(service.name)}", "Новый продукт на полигоне", body, PORTAL_URL, "Открыть полигон")
|
||
try:
|
||
_send_email(user.username, f"Доступ к пилоту «{service.name}» — полигон MONT", html)
|
||
return "Email отправлен"
|
||
except Exception as ex:
|
||
log_event("email_send_error", error=str(ex), channel="pilot_granted")
|
||
return f"Ошибка email: {ex}"
|
||
|
||
|
||
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}"}],
|
||
]
|
||
}
|
||
|
||
|
||
# --- Second approval channel: email ----------------------------------------
|
||
# Alongside the Telegram message, a notification with click-to-confirm links
|
||
# goes to ADMIN_NOTIFY_EMAIL. Each link only opens a confirmation page (GET) -
|
||
# it does NOT grant/reject anything by itself, because corporate mail
|
||
# security (e.g. Microsoft Safe Links) pre-fetches every link in a message
|
||
# with a plain GET before a human ever opens the email. The actual decision
|
||
# is only applied on the POST triggered by the "Подтвердить" button on that
|
||
# page. Whichever channel (Telegram or email) is acted on first wins - the
|
||
# other just shows "уже обработано" afterwards.
|
||
|
||
_ACTION_DAYS = {"a7": 7, "a14": 14, "a30": 30, "a90": 90, "r": None}
|
||
_ACTION_LABELS = {"a7": "7 дней", "a14": "14 дней", "a30": "30 дней", "a90": "90 дней", "r": "Отказать"}
|
||
|
||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer as _URLSafeTimedSerializer
|
||
|
||
_email_decision_serializer = _URLSafeTimedSerializer(SIGNING_KEY, salt="email-access-decision")
|
||
_EMAIL_DECISION_MAX_AGE_SECONDS = 30 * 24 * 3600 # link stays clickable for 30 days
|
||
|
||
def _make_decision_token(req_id: str, action: str) -> str:
|
||
return _email_decision_serializer.dumps({"req_id": req_id, "action": action})
|
||
|
||
def _verify_decision_token(token: str, req_id: str, action: str) -> bool:
|
||
try:
|
||
data = _email_decision_serializer.loads(token, max_age=_EMAIL_DECISION_MAX_AGE_SECONDS)
|
||
except (BadSignature, SignatureExpired):
|
||
return False
|
||
return data.get("req_id") == req_id and data.get("action") == action
|
||
|
||
|
||
def _apply_access_decision(db, pending, days):
|
||
"""Approve (days=int) or reject (days=None) a pending access request:
|
||
creates/renews the user, assigns requested services, commits, and emails
|
||
the requester. Used by the email-approval channel below. Mirrors the
|
||
Telegram approve/reject logic in telegram_webhook() / _process_callback_query()
|
||
- keep the email templates and grant logic in sync if you change those."""
|
||
import json as _jg
|
||
|
||
products = _jg.loads(pending.products_json or "[]")
|
||
portal_url = pending.portal_url or PORTAL_URL
|
||
|
||
if days is not None:
|
||
username = pending.email
|
||
expires = now_utc() + dt.timedelta(days=days)
|
||
parts = pending.name.strip().split(None, 1)
|
||
|
||
existing_user = db.scalar(select(User).where(User.username == username))
|
||
is_renewal = existing_user is not None
|
||
|
||
if is_renewal:
|
||
password = None
|
||
existing_user.expires_at = expires
|
||
existing_user.active = True
|
||
target_user = existing_user
|
||
db.flush()
|
||
else:
|
||
password = _generate_password()
|
||
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()
|
||
target_user = new_user
|
||
|
||
if products:
|
||
# Python-side .strip() (not SQL trim()) so this is robust to any
|
||
# whitespace - Postgres trim()/btrim() only strip plain spaces by
|
||
# default, not tabs, which is exactly the byte that silently broke
|
||
# this match for one product that had a leading tab in its name.
|
||
_active_by_name = {
|
||
svc.name.strip().lower(): svc
|
||
for svc in db.scalars(select(Service).where(Service.active == True)).all()
|
||
}
|
||
# Dedup by the *matched service*, not just the raw product
|
||
# string - a product tagged under 2+ categories (e.g. Deckhouse
|
||
# Platform in both "Платформы виртуализации" and "Кубернетес") shows up as two
|
||
# separate checkboxes in the request modal, so "Выбрать все" submits its
|
||
# name twice - without this, that duplicate (user_id, service_id)
|
||
# pair violates uq_user_service and rolls back the *entire*
|
||
# approval (no user, no grants, status stays "pending") with only
|
||
# a silent tg_callback_error in the logs to show for it.
|
||
_wanted_names = {p.strip().lower() for p in products}
|
||
matched = [svc for name, svc in _active_by_name.items() if name in _wanted_names]
|
||
existing_svc_ids = (
|
||
{a.service_id for a in db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == target_user.id)).all()}
|
||
if is_renewal else set()
|
||
)
|
||
for svc in matched:
|
||
if svc.id not in existing_svc_ids:
|
||
db.add(UserServiceAccess(user_id=target_user.id, service_id=svc.id))
|
||
|
||
pending.status = "approved"
|
||
db.commit()
|
||
|
||
products_html = ""
|
||
if products:
|
||
items = "".join(f"<li>{escape(p)}</li>" for p in products)
|
||
products_html = (
|
||
'<p style="margin:16px 0 6px;color:#45566e"><b>Предоставлен доступ к продуктам:</b></p>'
|
||
f'<ul style="margin:0;padding-left:20px;color:#45566e">{items}</ul>'
|
||
)
|
||
|
||
day_word = "день" if days == 1 else ("дня" if days < 5 else "дней")
|
||
email_action = "продлён" if is_renewal else "предоставлен"
|
||
email_subject = ("Продление доступа к Инфраструктурному полигону MONT"
|
||
if is_renewal else
|
||
"Доступ к Инфраструктурному полигону MONT")
|
||
email_subhead = "Ваш доступ продлён" if is_renewal else "Ваш запрос одобрен"
|
||
cred_row = (
|
||
f'<tr><td style="color:#7c8ba0;font-size:13px;border-top:1px solid #e7ebf0">Пароль</td>'
|
||
f'<td style="border-top:1px solid #e7ebf0;color:#14233a;font-size:14px;font-family:monospace">{password}</td></tr>'
|
||
) if not is_renewal else ""
|
||
access_text = f"Вам {email_action} доступ к полигону на <b>{days} {day_word}</b>."
|
||
html_email = f"""<!DOCTYPE html>
|
||
<html lang="ru"><head><meta charset="utf-8"/></head>
|
||
<body style="margin:0;padding:0;background:#eef1f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
|
||
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 20px">
|
||
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e3e8ee">
|
||
<tr><td style="padding:32px 36px 0">
|
||
<img src="cid:mont_logo" alt="MONT" width="160" height="46" style="display:block;margin-bottom:24px;border:0"/>
|
||
<h1 style="margin:0 0 8px;font-size:22px;color:#14233a">{email_subject}</h1>
|
||
<p style="margin:0 0 24px;color:#7c8ba0;font-size:14px">{email_subhead}</p>
|
||
<hr style="border:none;border-top:1px solid #e7ebf0;margin:0 0 24px"/>
|
||
</td></tr>
|
||
<tr><td style="padding:0 36px">
|
||
<p style="color:#45566e;font-size:15px;line-height:1.6;margin:0 0 20px">Здравствуйте, <b>{escape(pending.name)}</b>!<br>
|
||
{access_text}</p>
|
||
<table width="100%" cellpadding="12" cellspacing="0" style="background:#f7f9fb;border-radius:10px;border:1px solid #e7ebf0">
|
||
<tr><td style="color:#7c8ba0;font-size:13px;width:40%">Адрес портала</td>
|
||
<td><a href="{portal_url}" style="color:#1a5db5;font-size:14px">{portal_url}</a></td></tr>
|
||
<tr><td style="color:#7c8ba0;font-size:13px;border-top:1px solid #e7ebf0">Логин</td>
|
||
<td style="border-top:1px solid #e7ebf0;color:#14233a;font-size:14px;font-family:monospace">{username}</td></tr>
|
||
{cred_row}
|
||
<tr><td style="color:#7c8ba0;font-size:13px;border-top:1px solid #e7ebf0">Доступ до</td>
|
||
<td style="border-top:1px solid #e7ebf0;color:#14233a;font-size:14px">{expires.strftime("%d.%m.%Y")}</td></tr>
|
||
</table>
|
||
{products_html}
|
||
<div style="margin:28px 0">
|
||
<a href="{portal_url}" style="display:inline-block;padding:12px 28px;background:#1a5db5;color:#fff;text-decoration:none;border-radius:8px;font-size:15px;font-weight:600">Войти в полигон</a>
|
||
</div>
|
||
</td></tr>
|
||
<tr><td style="padding:20px 36px 28px;color:#8b98ab;font-size:12px;border-top:1px solid #e7ebf0;line-height:1.6">
|
||
Если у вас возникли вопросы, свяжитесь с вашим менеджером MONT или напишите на <a href="mailto:RGalyaviev@mont.ru" style="color:#1a5db5">RGalyaviev@mont.ru</a>
|
||
</td></tr>
|
||
</table></td></tr></table>
|
||
</body></html>"""
|
||
|
||
try:
|
||
_send_email(pending.email, email_subject, html_email)
|
||
email_status = "Email отправлен"
|
||
except Exception as ex:
|
||
log_event("email_send_error", error=str(ex), channel="email_approval")
|
||
email_status = f"Ошибка email: {ex}"
|
||
|
||
return {
|
||
"kind": "approved", "is_renewal": is_renewal, "days": days,
|
||
"username": username, "password": password, "email_status": email_status,
|
||
}
|
||
|
||
else:
|
||
manager_contact = pending.manager if pending.manager else "менеджера MONT"
|
||
html_email = f"""<!DOCTYPE html>
|
||
<html lang="ru"><head><meta charset="utf-8"/></head>
|
||
<body style="margin:0;padding:0;background:#eef1f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
|
||
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 20px">
|
||
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e3e8ee">
|
||
<tr><td style="padding:32px 36px 0">
|
||
<img src="cid:mont_logo" alt="MONT" width="160" height="46" style="display:block;margin-bottom:24px;border:0"/>
|
||
<h1 style="margin:0 0 8px;font-size:22px;color:#14233a">Запрос на доступ к полигону MONT</h1>
|
||
<hr style="border:none;border-top:1px solid #e7ebf0;margin:16px 0 24px"/>
|
||
</td></tr>
|
||
<tr><td style="padding:0 36px">
|
||
<p style="color:#45566e;font-size:15px;line-height:1.7;margin:0 0 20px">Здравствуйте, <b>{escape(pending.name)}</b>!<br><br>
|
||
К сожалению, на данный момент мы не можем предоставить доступ к полигону.</p>
|
||
<p style="color:#45566e;font-size:15px;line-height:1.7;margin:0 0 24px">
|
||
Для уточнения деталей свяжитесь с <b>{escape(manager_contact)}</b>.<br>
|
||
Если не знаете кто ваш менеджер — напишите на <a href="mailto:RGalyaviev@mont.ru" style="color:#1a5db5">RGalyaviev@mont.ru</a>.</p>
|
||
</td></tr>
|
||
<tr><td style="padding:20px 36px 28px;color:#8b98ab;font-size:12px;border-top:1px solid #e7ebf0">
|
||
С уважением, команда MONT
|
||
</td></tr>
|
||
</table></td></tr></table>
|
||
</body></html>"""
|
||
|
||
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), channel="email_approval")
|
||
email_status = f"Ошибка email: {ex}"
|
||
|
||
return {"kind": "rejected", "email_status": email_status}
|
||
|
||
|
||
def _send_admin_decision_email(pending) -> None:
|
||
"""Send the email-approval notification for a freshly created pending
|
||
request, alongside the Telegram message. Best-effort: failures are
|
||
logged, not raised (a broken email channel must never block the
|
||
Telegram channel from working)."""
|
||
if not ADMIN_NOTIFY_EMAIL:
|
||
return
|
||
decide_base = f"{PORTAL_URL}/admin/access-request/{pending.id}/decide"
|
||
buttons_html = ""
|
||
for action in ("a7", "a14", "a30", "a90"):
|
||
token = _make_decision_token(pending.id, action)
|
||
url = f"{decide_base}?action={action}&token={_urllib_parse.quote(token)}"
|
||
buttons_html += (
|
||
f'<a href="{url}" style="display:inline-block;margin:4px 6px 0 0;padding:10px 18px;'
|
||
f'background:#1a5db5;color:#fff;text-decoration:none;border-radius:6px;font-size:14px">{_ACTION_LABELS[action]}</a>'
|
||
)
|
||
reject_token = _make_decision_token(pending.id, "r")
|
||
reject_url = f"{decide_base}?action=r&token={_urllib_parse.quote(reject_token)}"
|
||
buttons_html += (
|
||
f'<a href="{reject_url}" style="display:inline-block;margin:4px 0 0;padding:10px 18px;'
|
||
f'background:#7a2b2b;color:#fff;text-decoration:none;border-radius:6px;font-size:14px">Отказать</a>'
|
||
)
|
||
|
||
products_line = ""
|
||
try:
|
||
import json as _jg2
|
||
items = _jg2.loads(pending.products_json or "[]")
|
||
if items:
|
||
products_line = ('<p style="margin:16px 0 4px;color:#7a9abd;font-size:13px">Продукты: '
|
||
+ ", ".join(escape(str(p)) for p in items) + '</p>')
|
||
except Exception:
|
||
pass
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="ru"><head><meta charset="utf-8"/></head>
|
||
<body style="margin:0;padding:0;background:#0a1929;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
|
||
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:32px 20px">
|
||
<table width="560" cellpadding="0" cellspacing="0" style="background:#0d2040;border-radius:14px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)">
|
||
<tr><td style="padding:28px 32px 0">
|
||
<h1 style="margin:0 0 6px;font-size:20px;color:#e8f1fb">Новый запрос доступа к полигону MONT</h1>
|
||
<p style="margin:0 0 20px;color:#7a9abd;font-size:13px">Второй канал согласования — дублирует запрос, отправленный в Telegram</p>
|
||
<hr style="border:none;border-top:1px solid rgba(255,255,255,0.08);margin:0 0 20px"/>
|
||
</td></tr>
|
||
<tr><td style="padding:0 32px">
|
||
<table width="100%" cellpadding="8" cellspacing="0" style="background:rgba(255,255,255,0.04);border-radius:10px;border:1px solid rgba(255,255,255,0.07);color:#c8d8ea;font-size:14px">
|
||
<tr><td style="color:#7a9abd;width:35%">Имя</td><td>{escape(pending.name)}</td></tr>
|
||
<tr><td style="color:#7a9abd">Компания</td><td>{escape(pending.company)}</td></tr>
|
||
<tr><td style="color:#7a9abd">Email</td><td>{escape(pending.email)}</td></tr>
|
||
<tr><td style="color:#7a9abd">Телефон</td><td>{escape(pending.phone)}</td></tr>
|
||
<tr><td style="color:#7a9abd">Менеджер</td><td>{escape(pending.manager) if pending.manager else "—"}</td></tr>
|
||
</table>
|
||
{products_line}
|
||
<p style="margin:24px 0 8px;color:#c8d8ea;font-size:14px">Выдать доступ на:</p>
|
||
<div>{buttons_html}</div>
|
||
<p style="margin:20px 0 0;color:#4a6a8a;font-size:12px">Ссылка открывает страницу подтверждения — доступ выдаётся только по нажатию кнопки на ней, а не по самому переходу (безопасно для антифишинг-сканеров почты, которые сами открывают ссылки).</p>
|
||
</td></tr>
|
||
<tr><td style="padding:20px 32px 28px;color:#4a6a8a;font-size:12px;border-top:1px solid rgba(255,255,255,0.06)">
|
||
Заявка #{pending.id}
|
||
</td></tr>
|
||
</table></td></tr></table>
|
||
</body></html>"""
|
||
|
||
try:
|
||
_send_email(ADMIN_NOTIFY_EMAIL, "Новый запрос доступа к полигону MONT", html)
|
||
except Exception as ex:
|
||
log_event("email_send_error", error=str(ex), channel="admin_notify")
|
||
|
||
|
||
def _decision_page(title: str, body: str):
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="ru"><head><meta charset="utf-8"/><title>{title}</title>
|
||
<meta name="robots" content="noindex,nofollow"/></head>
|
||
<body style="margin:0;padding:0;background:#0a1929;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#c8d8ea">
|
||
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:60px 20px">
|
||
<table width="480" cellpadding="0" cellspacing="0" style="background:#0d2040;border-radius:14px;border:1px solid rgba(255,255,255,0.08)">
|
||
<tr><td style="padding:32px">{body}</td></tr>
|
||
</table></td></tr></table>
|
||
</body></html>"""
|
||
return _HR(html)
|
||
|
||
|
||
|
||
_tg_poll_offset: int = 0
|
||
_tg_poll_lock_file = None
|
||
|
||
async def _telegram_poll_loop():
|
||
"""Poll Telegram for callback_query updates every 5 minutes."""
|
||
import asyncio as _asyncio
|
||
global _tg_poll_offset
|
||
await _asyncio.sleep(10) # wait for app to fully start
|
||
while True:
|
||
try:
|
||
result = _tg_api("getUpdates", {
|
||
"offset": _tg_poll_offset,
|
||
"timeout": 0,
|
||
"allowed_updates": ["callback_query"],
|
||
})
|
||
updates = result.get("result", [])
|
||
for upd in updates:
|
||
_tg_poll_offset = upd["update_id"] + 1
|
||
cq = upd.get("callback_query")
|
||
if cq:
|
||
try:
|
||
await _process_callback_query(cq)
|
||
except Exception as ex:
|
||
log_event("tg_callback_error", error=str(ex))
|
||
except Exception as ex:
|
||
log_event("tg_poll_error", error=str(ex))
|
||
await _asyncio.sleep(30) # 30 seconds
|
||
|
||
|
||
async def _telegram_notify_retry_loop():
|
||
"""Every 5 minutes, resend the initial 'new request' Telegram
|
||
notification for any pending request whose first attempt failed (e.g.
|
||
tel.4mont.ru was down/timed out) - see the 2026-08-18 incident where a
|
||
request's only notification attempt failed and nobody found out until
|
||
the requester followed up separately. Stops retrying once the request
|
||
is no longer 'pending' (decided via Telegram or the email channel) or
|
||
once a send finally succeeds."""
|
||
import asyncio as _asyncio3
|
||
from database import SessionLocal as _SL3
|
||
await _asyncio3.sleep(60) # let the app finish starting first
|
||
while True:
|
||
try:
|
||
db = _SL3()
|
||
try:
|
||
pending_rows = db.scalars(
|
||
select(PendingAccessRequest).where(
|
||
PendingAccessRequest.status == "pending",
|
||
PendingAccessRequest.telegram_notified == False,
|
||
PendingAccessRequest.telegram_message != "",
|
||
)
|
||
).all()
|
||
for pending in pending_rows:
|
||
try:
|
||
_tg_api("sendMessage", {
|
||
"chat_id": TELEGRAM_CHAT_ID,
|
||
"text": pending.telegram_message,
|
||
"parse_mode": "HTML",
|
||
"reply_markup": _make_approval_keyboard(pending.id),
|
||
})
|
||
pending.telegram_notified = True
|
||
db.commit()
|
||
log_event("telegram_notify_retry_success", req_id=pending.id)
|
||
except Exception as ex:
|
||
log_event("telegram_notify_retry_error", req_id=pending.id, error=str(ex))
|
||
finally:
|
||
db.close()
|
||
except Exception as ex:
|
||
log_event("telegram_notify_retry_loop_error", error=str(ex))
|
||
await _asyncio3.sleep(300) # 5 минут
|
||
|
||
|
||
async def _process_callback_query(cq: dict):
|
||
import json as _jc
|
||
import datetime as _dtc
|
||
import re as _rec
|
||
from database import SessionLocal as _SL
|
||
|
||
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
|
||
|
||
approve_match = _rec.match(r'^a(\d+)_(.+)$', cb_data)
|
||
reject_match = _rec.match(r'^r_(.+)$', cb_data)
|
||
|
||
if not approve_match and not reject_match:
|
||
return
|
||
|
||
req_id = approve_match.group(2) if approve_match else reject_match.group(1)
|
||
|
||
db = _SL()
|
||
try:
|
||
pending = db.get(PendingAccessRequest, req_id)
|
||
if not pending:
|
||
_tg_api("editMessageText", {
|
||
"chat_id": chat_id, "message_id": msg_id,
|
||
"text": "Запрос не найден (возможно уже обработан).",
|
||
})
|
||
return
|
||
|
||
if pending.status != "pending":
|
||
_tg_api("editMessageText", {
|
||
"chat_id": chat_id, "message_id": msg_id,
|
||
"text": f"Запрос уже обработан: {pending.status}.",
|
||
})
|
||
return
|
||
|
||
products = _jc.loads(pending.products_json or "[]")
|
||
portal_url = pending.portal_url or PORTAL_URL
|
||
|
||
if approve_match:
|
||
days = int(approve_match.group(1))
|
||
username = pending.email
|
||
expires = _dtc.datetime.now(_dtc.timezone.utc) + _dtc.timedelta(days=days)
|
||
parts = pending.name.strip().split(None, 1)
|
||
|
||
existing_user = db.scalar(select(User).where(User.username == username))
|
||
is_renewal = existing_user is not None
|
||
|
||
if is_renewal:
|
||
password = None
|
||
existing_user.expires_at = expires
|
||
existing_user.active = True
|
||
target_user = existing_user
|
||
db.flush()
|
||
else:
|
||
password = _generate_password()
|
||
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()
|
||
target_user = new_user
|
||
|
||
if products:
|
||
_active_by_name = {
|
||
svc.name.strip().lower(): svc
|
||
for svc in db.scalars(select(Service).where(Service.active == True)).all()
|
||
}
|
||
# Dedup by the *matched service*, not just the raw product
|
||
# string - a product tagged under 2+ categories (e.g. Deckhouse
|
||
# Platform in both "Платформы виртуализации" and "Кубернетес") shows up as two
|
||
# separate checkboxes in the request modal, so "Выбрать все" submits its
|
||
# name twice - without this, that duplicate (user_id, service_id)
|
||
# pair violates uq_user_service and rolls back the *entire*
|
||
# approval (no user, no grants, status stays "pending") with only
|
||
# a silent tg_callback_error in the logs to show for it.
|
||
_wanted_names = {p.strip().lower() for p in products}
|
||
matched = [svc for name, svc in _active_by_name.items() if name in _wanted_names]
|
||
existing_svc_ids = (
|
||
{a.service_id for a in db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == target_user.id)).all()}
|
||
if is_renewal else set()
|
||
)
|
||
for svc in matched:
|
||
if svc.id not in existing_svc_ids:
|
||
db.add(UserServiceAccess(user_id=target_user.id, service_id=svc.id))
|
||
|
||
db.commit()
|
||
|
||
products_html = ""
|
||
if products:
|
||
items = "".join(f"<li>{escape(p)}</li>" for p in products)
|
||
products_html = (
|
||
'<p style="margin:16px 0 6px;color:#45566e"><b>Предоставлен доступ к продуктам:</b></p>'
|
||
f'<ul style="margin:0;padding-left:20px;color:#45566e">{items}</ul>'
|
||
)
|
||
|
||
day_word = "день" if days == 1 else ("дня" if days < 5 else "дней")
|
||
email_action = "продлён" if is_renewal else "предоставлен"
|
||
email_subject = ("Продление доступа к Инфраструктурному полигону MONT"
|
||
if is_renewal else
|
||
"Доступ к Инфраструктурному полигону MONT")
|
||
email_subhead = "Ваш доступ продлён" if is_renewal else "Ваш запрос одобрен"
|
||
cred_row = (
|
||
f'<tr><td style="color:#7c8ba0;font-size:13px;border-top:1px solid #e7ebf0">Пароль</td>'
|
||
f'<td style="border-top:1px solid #e7ebf0;color:#14233a;font-size:14px;font-family:monospace">{password}</td></tr>'
|
||
) if not is_renewal else ""
|
||
access_text = f"Вам {email_action} доступ к полигону на <b>{days} {day_word}</b>."
|
||
html_email = f"""<!DOCTYPE html>
|
||
<html lang="ru"><head><meta charset="utf-8"/></head>
|
||
<body style="margin:0;padding:0;background:#eef1f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
|
||
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 20px">
|
||
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e3e8ee">
|
||
<tr><td style="padding:32px 36px 0">
|
||
<img src="cid:mont_logo" alt="MONT" width="160" height="46" style="display:block;margin-bottom:24px;border:0"/>
|
||
<h1 style="margin:0 0 8px;font-size:22px;color:#14233a">{email_subject}</h1>
|
||
<p style="margin:0 0 24px;color:#7c8ba0;font-size:14px">{email_subhead}</p>
|
||
<hr style="border:none;border-top:1px solid #e7ebf0;margin:0 0 24px"/>
|
||
</td></tr>
|
||
<tr><td style="padding:0 36px">
|
||
<p style="color:#45566e;font-size:15px;line-height:1.6;margin:0 0 20px">Здравствуйте, <b>{escape(pending.name)}</b>!<br>
|
||
{access_text}</p>
|
||
<table width="100%" cellpadding="12" cellspacing="0" style="background:#f7f9fb;border-radius:10px;border:1px solid #e7ebf0">
|
||
<tr><td style="color:#7c8ba0;font-size:13px;width:40%">Адрес портала</td>
|
||
<td><a href="{portal_url}" style="color:#1a5db5;font-size:14px">{portal_url}</a></td></tr>
|
||
<tr><td style="color:#7c8ba0;font-size:13px;border-top:1px solid #e7ebf0">Логин</td>
|
||
<td style="border-top:1px solid #e7ebf0;color:#14233a;font-size:14px;font-family:monospace">{username}</td></tr>
|
||
{cred_row}
|
||
<tr><td style="color:#7c8ba0;font-size:13px;border-top:1px solid #e7ebf0">Доступ до</td>
|
||
<td style="border-top:1px solid #e7ebf0;color:#14233a;font-size:14px">{expires.strftime("%d.%m.%Y")}</td></tr>
|
||
</table>
|
||
{products_html}
|
||
<div style="margin:28px 0">
|
||
<a href="{portal_url}" style="display:inline-block;padding:12px 28px;background:#1a5db5;color:#fff;text-decoration:none;border-radius:8px;font-size:15px;font-weight:600">Войти в полигон</a>
|
||
</div>
|
||
</td></tr>
|
||
<tr><td style="padding:20px 36px 28px;color:#8b98ab;font-size:12px;border-top:1px solid #e7ebf0;line-height:1.6">
|
||
Если у вас возникли вопросы, свяжитесь с вашим менеджером MONT или напишите на <a href="mailto:RGalyaviev@mont.ru" style="color:#1a5db5">RGalyaviev@mont.ru</a>
|
||
</td></tr>
|
||
</table></td></tr></table>
|
||
</body></html>"""
|
||
|
||
pending.status = "approved"
|
||
db.commit()
|
||
|
||
try:
|
||
_send_email(pending.email, email_subject, html_email)
|
||
email_status = "Email отправлен"
|
||
except Exception as ex:
|
||
log_event("email_send_error", error=str(ex))
|
||
email_status = f"Ошибка email: {ex}"
|
||
|
||
_tg_api("editMessageText", {
|
||
"chat_id": chat_id, "message_id": msg_id,
|
||
"text": (
|
||
(f"🔄 Продлено на {days} {day_word}\n" if is_renewal else f"✅ Одобрено на {days} {day_word}\n")
|
||
+ f"👤 Логин: `{username}`\n"
|
||
+ (f"🔑 Пароль: `{password}`\n" if not is_renewal else "")
|
||
+ f"📧 {email_status}"
|
||
),
|
||
"parse_mode": "Markdown",
|
||
})
|
||
|
||
elif reject_match:
|
||
manager_contact = pending.manager if pending.manager else "менеджера MONT"
|
||
html_email = f"""<!DOCTYPE html>
|
||
<html lang="ru"><head><meta charset="utf-8"/></head>
|
||
<body style="margin:0;padding:0;background:#eef1f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
|
||
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 20px">
|
||
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e3e8ee">
|
||
<tr><td style="padding:32px 36px 0">
|
||
<img src="cid:mont_logo" alt="MONT" width="160" height="46" style="display:block;margin-bottom:24px;border:0"/>
|
||
<h1 style="margin:0 0 8px;font-size:22px;color:#14233a">Запрос на доступ к полигону MONT</h1>
|
||
<hr style="border:none;border-top:1px solid #e7ebf0;margin:16px 0 24px"/>
|
||
</td></tr>
|
||
<tr><td style="padding:0 36px">
|
||
<p style="color:#45566e;font-size:15px;line-height:1.7;margin:0 0 20px">Здравствуйте, <b>{escape(pending.name)}</b>!<br><br>
|
||
К сожалению, на данный момент мы не можем предоставить доступ к полигону.</p>
|
||
<p style="color:#45566e;font-size:15px;line-height:1.7;margin:0 0 24px">
|
||
Для уточнения деталей свяжитесь с <b>{escape(manager_contact)}</b>.<br>
|
||
Если не знаете кто ваш менеджер — напишите на <a href="mailto:RGalyaviev@mont.ru" style="color:#1a5db5">RGalyaviev@mont.ru</a>.</p>
|
||
</td></tr>
|
||
<tr><td style="padding:20px 36px 28px;color:#8b98ab;font-size:12px;border-top:1px solid #e7ebf0">
|
||
С уважением, команда MONT
|
||
</td></tr>
|
||
</table></td></tr></table>
|
||
</body></html>"""
|
||
|
||
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}"
|
||
|
||
_tg_api("editMessageText", {
|
||
"chat_id": chat_id, "message_id": msg_id,
|
||
"text": f"❌ Отклонено. {email_status}",
|
||
})
|
||
finally:
|
||
db.close()
|
||
|
||
app = FastAPI(title="MONT - инфрастуктурный полигон", docs_url=None, redoc_url=None, openapi_url=None)
|
||
|
||
|
||
@app.get("/admin/access-request/{req_id}/decide")
|
||
def access_request_decide_confirm(req_id: str, action: str, token: str, db: Session = Depends(get_db)):
|
||
"""GET only renders a confirmation page - it must never mutate state,
|
||
because corporate mail scanners (e.g. Microsoft Safe Links) pre-fetch
|
||
every link in an email with a GET before a human reads it."""
|
||
if action not in _ACTION_DAYS or not _verify_decision_token(token, req_id, action):
|
||
return _decision_page("Ссылка недействительна", "<p>Ссылка недействительна или устарела.</p>")
|
||
|
||
pending = db.get(PendingAccessRequest, req_id)
|
||
if not pending:
|
||
return _decision_page("Запрос не найден", "<p>Запрос не найден (возможно, уже обработан).</p>")
|
||
if pending.status != "pending":
|
||
return _decision_page("Уже обработано", f"<p>Запрос уже обработан: {pending.status}.</p>")
|
||
|
||
decision_label = "Отказать" if action == "r" else f"Одобрить на {_ACTION_LABELS[action]}"
|
||
body = f"""
|
||
<h2 style="margin:0 0 16px;color:#e8f1fb;font-size:20px">Подтвердите решение</h2>
|
||
<p><b>{escape(pending.name)}</b> ({escape(pending.company)})<br>{escape(pending.email)} · {escape(pending.phone)}</p>
|
||
<p style="margin:20px 0">Решение: <b>{decision_label}</b></p>
|
||
<form method="post" action="/admin/access-request/{req_id}/decide">
|
||
<input type="hidden" name="action" value="{action}"/>
|
||
<input type="hidden" name="token" value="{token}"/>
|
||
<button type="submit" style="padding:12px 24px;background:{'#7a2b2b' if action == 'r' else '#1a5db5'};color:#fff;
|
||
border:none;border-radius:8px;font-size:15px;cursor:pointer">Подтвердить</button>
|
||
</form>
|
||
"""
|
||
return _decision_page("Подтверждение решения", body)
|
||
|
||
|
||
@app.post("/admin/access-request/{req_id}/decide")
|
||
def access_request_decide_apply(req_id: str, action: str = Form(...), token: str = Form(...), db: Session = Depends(get_db)):
|
||
if action not in _ACTION_DAYS or not _verify_decision_token(token, req_id, action):
|
||
return _decision_page("Ссылка недействительна", "<p>Ссылка недействительна или устарела.</p>")
|
||
|
||
pending = db.get(PendingAccessRequest, req_id)
|
||
if not pending:
|
||
return _decision_page("Запрос не найден", "<p>Запрос не найден (возможно, уже обработан).</p>")
|
||
if pending.status != "pending":
|
||
return _decision_page("Уже обработано", f"<p>Запрос уже обработан: {pending.status}.</p>")
|
||
|
||
days = _ACTION_DAYS[action]
|
||
result = _apply_access_decision(db, pending, days)
|
||
|
||
if result["kind"] == "approved":
|
||
day_word = "день" if days == 1 else ("дня" if days < 5 else "дней")
|
||
cred = f"<p>Логин: <code>{escape(result['username'])}</code></p>"
|
||
if not result["is_renewal"]:
|
||
cred += f"<p>Пароль: <code>{escape(result['password'])}</code></p>"
|
||
title_word = "Продлено" if result["is_renewal"] else "Одобрено"
|
||
body = f"""
|
||
<h2 style="margin:0 0 16px;color:#e8f1fb;font-size:20px">{title_word} на {days} {day_word}</h2>
|
||
{cred}
|
||
<p style="color:#7a9abd">{escape(result['email_status'])}</p>
|
||
"""
|
||
else:
|
||
body = f"""
|
||
<h2 style="margin:0 0 16px;color:#e8f1fb;font-size:20px">Отклонено</h2>
|
||
<p style="color:#7a9abd">{escape(result['email_status'])}</p>
|
||
"""
|
||
return _decision_page("Готово", body)
|
||
|
||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||
|
||
|
||
@app.middleware("http")
|
||
async def request_logging_middleware(request: Request, call_next):
|
||
req_id = request.headers.get("X-Request-ID", str(uuid.uuid4())[:8])
|
||
token = request_id_ctx.set(req_id)
|
||
started = time.time()
|
||
client_ip = request.client.host if request.client else "-"
|
||
user_agent = request.headers.get("user-agent", "-")
|
||
try:
|
||
response = await call_next(request)
|
||
except Exception:
|
||
log_event(
|
||
"request_failed",
|
||
level=logging.ERROR,
|
||
method=request.method,
|
||
path=request.url.path,
|
||
client_ip=client_ip,
|
||
user_agent=user_agent,
|
||
)
|
||
request_id_ctx.reset(token)
|
||
raise
|
||
duration_ms = int((time.time() - started) * 1000)
|
||
level = logging.INFO
|
||
if response.status_code >= 500:
|
||
level = logging.ERROR
|
||
elif response.status_code >= 400:
|
||
level = logging.WARNING
|
||
log_event(
|
||
"request",
|
||
level=level,
|
||
method=request.method,
|
||
path=request.url.path,
|
||
query=str(request.url.query or ""),
|
||
status=response.status_code,
|
||
duration_ms=duration_ms,
|
||
client_ip=client_ip,
|
||
user_agent=user_agent,
|
||
)
|
||
if duration_ms >= LOG_SLOW_REQUEST_MS:
|
||
log_event(
|
||
"slow_request",
|
||
level=logging.WARNING,
|
||
method=request.method,
|
||
path=request.url.path,
|
||
duration_ms=duration_ms,
|
||
threshold_ms=LOG_SLOW_REQUEST_MS,
|
||
)
|
||
response.headers["X-Request-ID"] = req_id
|
||
request_id_ctx.reset(token)
|
||
return response
|
||
|
||
|
||
_MOBILE_UA_RE = re.compile(
|
||
r"(Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|webOS)",
|
||
re.IGNORECASE,
|
||
)
|
||
_MOBILE_PAGE = (
|
||
"<!doctype html>"
|
||
'<html lang="ru">'
|
||
"<head>"
|
||
'<meta charset="utf-8"/>'
|
||
'<meta name="viewport" content="width=device-width,initial-scale=1"/>'
|
||
"<title>MONT - инфрастуктурный полигон</title>"
|
||
"<style>"
|
||
"*{box-sizing:border-box;margin:0;padding:0}"
|
||
"body{min-height:100dvh;display:flex;flex-direction:column;align-items:center;"
|
||
"justify-content:center;background:linear-gradient(160deg,#0a2a4a 0%,#1565a0 60%,#1e88c8 100%);"
|
||
"font-family:sans-serif;color:#fff;padding:2rem 1.5rem;text-align:center}"
|
||
".logo{width:120px;margin-bottom:2rem}"
|
||
"h1{font-size:1.3rem;font-weight:700;margin-bottom:1rem;line-height:1.35}"
|
||
"p{font-size:0.95rem;color:rgba(255,255,255,.75);line-height:1.5;max-width:280px}"
|
||
".icon{font-size:3.5rem;margin-bottom:1.2rem}"
|
||
"</style>"
|
||
"</head>"
|
||
"<body>"
|
||
'<img class="logo" src="/static/logo.png" alt="MONT"/>'
|
||
'<div class="icon">🖥</div>'
|
||
"<h1>Ресурс доступен<br>только с ПК</h1>"
|
||
"<p>Пожалуйста, откройте эту страницу на компьютере или ноутбуке.</p>"
|
||
"</body>"
|
||
"</html>"
|
||
)
|
||
|
||
|
||
# Public marketing/auth surface that has to work from a phone: the login
|
||
# page itself, submitting the login form, the "request access" flow (public
|
||
# form + the products list it loads), and static legal/SEO pages. Everything
|
||
# else (the actual stand: /go/, /svc/, /s/, /u/, /w/, /rdp/, /admin, ...)
|
||
# stays desktop-only - a phone can't usefully drive a remote desktop/browser
|
||
# session anyway, and the dashboard has its own width-based #mobile-wall on
|
||
# top of this for narrow desktop windows.
|
||
_MOBILE_ALLOWED_PATHS = {
|
||
"/",
|
||
"/login",
|
||
"/privacy",
|
||
"/robots.txt",
|
||
"/sitemap.xml",
|
||
"/favicon.ico",
|
||
"/api/public/services-by-category",
|
||
"/api/request-access",
|
||
}
|
||
_MOBILE_ALLOWED_PREFIXES = ("/static/", "/admin/access-request/", "/product/")
|
||
|
||
|
||
def _looks_authenticated(request: Request) -> bool:
|
||
"""Best-effort check for a plausibly-valid session cookie, without a DB
|
||
round trip. Good enough to decide the mobile gate on "/" - the actual
|
||
routes still enforce real auth via get_current_user/require_user."""
|
||
raw = request.cookies.get(COOKIE_NAME)
|
||
if not raw:
|
||
return False
|
||
try:
|
||
serializer.loads(raw, max_age=COOKIE_MAX_AGE)
|
||
except Exception:
|
||
return False
|
||
return True
|
||
|
||
|
||
@app.middleware("http")
|
||
async def mobile_block_middleware(request: Request, call_next):
|
||
path = request.url.path
|
||
if path.startswith(_MOBILE_ALLOWED_PREFIXES):
|
||
# email-approval links are meant to work from a phone too, same as
|
||
# approving from the Telegram app.
|
||
return await call_next(request)
|
||
if path in _MOBILE_ALLOWED_PATHS:
|
||
# "/" is shared by the logged-out login page (fine on a phone) and
|
||
# the authenticated dashboard (not fine - a phone can't drive a
|
||
# remote desktop/browser session), so it needs the real auth-cookie
|
||
# check instead of a blanket allow. Every other allowed path here
|
||
# (login POST, privacy, robots/sitemap, the public request-access
|
||
# API) never renders the dashboard, so they're always fine.
|
||
if path != "/" or not _looks_authenticated(request):
|
||
return await call_next(request)
|
||
ua = request.headers.get("user-agent", "")
|
||
if _MOBILE_UA_RE.search(ua):
|
||
return _HR(content=_MOBILE_PAGE, status_code=200)
|
||
return await call_next(request)
|
||
|
||
|
||
@app.on_event("startup")
|
||
async def startup_event():
|
||
global _tg_poll_lock_file
|
||
on_startup()
|
||
import asyncio as _aio, fcntl as _fcntl
|
||
_lf = open("/tmp/portal-tg-poll.lock", "w")
|
||
try:
|
||
_fcntl.flock(_lf.fileno(), _fcntl.LOCK_EX | _fcntl.LOCK_NB)
|
||
_tg_poll_lock_file = _lf
|
||
_aio.create_task(_telegram_poll_loop())
|
||
_aio.create_task(_telegram_notify_retry_loop())
|
||
except BlockingIOError:
|
||
_lf.close()
|
||
|
||
|
||
def _login_wall_services(db: Session):
|
||
"""Active, non-pilot services shown as a logo wall on the public login
|
||
page - the self-service "pick a product" catalog. Pilots are invite-only
|
||
and get their own separate, non-clickable teaser (_login_wall_pilots)."""
|
||
return db.scalars(
|
||
select(Service).where(Service.active == True, Service.is_pilot == False).order_by(Service.name)
|
||
).all()
|
||
|
||
|
||
def _login_wall_pilots(db: Session):
|
||
"""Active pilot services shown as a purely informational logo row on the
|
||
public login page - awareness only, not part of the self-service catalog
|
||
(no request-access entry point; admin grants these by hand)."""
|
||
return db.scalars(
|
||
select(Service).where(Service.active == True, Service.is_pilot == True).order_by(Service.name)
|
||
).all()
|
||
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
def index(request: Request, user: Optional[User] = Depends(get_current_user), db: Session = Depends(get_db)):
|
||
session_closed = (request.query_params.get("session_closed") or "").strip().lower()
|
||
launch_error = (request.query_params.get("launch_error") or "").strip().lower()
|
||
session_notice = ""
|
||
if session_closed == "idle":
|
||
session_notice = "Сессия была закрыта из-за простоя. Откройте сервис заново."
|
||
elif session_closed == "limit":
|
||
session_notice = (
|
||
f"Сессия была закрыта из-за лимита в {MAX_ACTIVE_SERVICES_PER_USER} сервиса(ов). "
|
||
"Освободите один сервис и попробуйте снова."
|
||
)
|
||
elif launch_error == "max_services":
|
||
session_notice = (
|
||
f"Есть ограничение на {MAX_ACTIVE_SERVICES_PER_USER} сервиса(ов). "
|
||
"Освободите один сервис и попробуйте снова."
|
||
)
|
||
if not user:
|
||
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
||
response = templates.TemplateResponse(
|
||
"login.html",
|
||
{
|
||
"request": request,
|
||
"csrf_token": csrf,
|
||
"login_error": "",
|
||
"session_notice": session_notice,
|
||
"public_services": _login_wall_services(db),
|
||
"public_pilots": _login_wall_pilots(db),
|
||
},
|
||
)
|
||
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="lax", path="/")
|
||
return response
|
||
|
||
all_services = db.scalars(
|
||
select(Service)
|
||
.where(Service.active == True, Service.type.in_([ServiceType.WEB, ServiceType.RDP]))
|
||
.order_by(Service.name)
|
||
).all()
|
||
# Per-grant expires_at (used by pilots) can make a grant expired even
|
||
# though the row still exists and the account itself is still valid -
|
||
# exclude those rows here so an expired pilot grant falls back into
|
||
# locked_services instead of staying "granted".
|
||
granted_ids = set(
|
||
db.scalars(
|
||
select(UserServiceAccess.service_id).where(
|
||
UserServiceAccess.user_id == user.id,
|
||
(UserServiceAccess.expires_at.is_(None)) | (UserServiceAccess.expires_at > now_utc()),
|
||
)
|
||
).all()
|
||
)
|
||
# Pilots the user has access to get their own collapsible section above
|
||
# the regular catalog instead of sitting in the "Ваши сервисы" grid.
|
||
pilot_services = [svc for svc in all_services if svc.id in granted_ids and svc.is_pilot]
|
||
services = [svc for svc in all_services if svc.id in granted_ids and not svc.is_pilot]
|
||
locked_services = [svc for svc in all_services if svc.id not in granted_ids]
|
||
pilot_ids = {svc.id for svc in all_services if svc.is_pilot}
|
||
|
||
# Categories are computed across the whole catalog (granted + locked) so
|
||
# the nav lets a user browse into a category they don't have access to
|
||
# yet and request it from there.
|
||
service_categories = {svc.id: [] for svc in all_services}
|
||
categories = []
|
||
if all_services:
|
||
service_ids = [svc.id for svc in all_services]
|
||
rows = db.execute(
|
||
select(ServiceCategory.service_id, Category.id, Category.name, Category.slug)
|
||
.join(Category, Category.id == ServiceCategory.category_id)
|
||
.where(ServiceCategory.service_id.in_(service_ids))
|
||
.order_by(Category.name)
|
||
).all()
|
||
category_map = {}
|
||
for service_id, category_id, category_name, category_slug in rows:
|
||
service_categories.setdefault(service_id, []).append(
|
||
{
|
||
"id": category_id,
|
||
"name": category_name,
|
||
"slug": category_slug,
|
||
}
|
||
)
|
||
if category_id not in category_map:
|
||
category_map[category_id] = {"id": category_id, "name": category_name, "slug": category_slug}
|
||
categories = sorted(category_map.values(), key=lambda x: x["name"].lower())
|
||
|
||
# Stable per-category counts (granted + locked), computed before any
|
||
# ?category= filtering is applied - the rail always shows the full
|
||
# catalog's numbers, not just what's currently on screen.
|
||
category_counts = {
|
||
cat["slug"]: sum(
|
||
1 for svc in all_services
|
||
if any(c["slug"] == cat["slug"] for c in service_categories.get(svc.id, []))
|
||
)
|
||
for cat in categories
|
||
}
|
||
|
||
selected_category_slug = (request.query_params.get("category") or "").strip().lower()
|
||
if selected_category_slug:
|
||
services = [
|
||
svc for svc in services
|
||
if any(cat["slug"] == selected_category_slug for cat in service_categories.get(svc.id, []))
|
||
]
|
||
locked_services = [
|
||
svc for svc in locked_services
|
||
if any(cat["slug"] == selected_category_slug for cat in service_categories.get(svc.id, []))
|
||
]
|
||
service_comment_html = {svc.id: format_service_comment(svc.comment) for svc in all_services}
|
||
|
||
return templates.TemplateResponse(
|
||
"dashboard.html",
|
||
{
|
||
"request": request,
|
||
"user": user,
|
||
"services": services,
|
||
"locked_services": locked_services,
|
||
"pilot_services": pilot_services,
|
||
"pilot_ids": pilot_ids,
|
||
"categories": categories,
|
||
"category_counts": category_counts,
|
||
"total_catalog_count": len(all_services),
|
||
"selected_category_slug": selected_category_slug,
|
||
"service_categories": service_categories,
|
||
"service_comment_html": service_comment_html,
|
||
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
|
||
"session_notice": session_notice,
|
||
"max_active_services": MAX_ACTIVE_SERVICES_PER_USER,
|
||
"idle_timeout_min": SESSION_IDLE_SECONDS // 60,
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/bundles", response_class=HTMLResponse)
|
||
def bundles_page(request: Request, user: User = Depends(require_user)):
|
||
"""Stub landing page for the upcoming "готовые связки" feature - curated
|
||
multi-product infrastructure scenarios (e.g. ALDpro + workstations, or
|
||
RuBackup + Alt PVE) granted and launched as one bundle instead of
|
||
picking products one by one. Not wired to real data yet - the scenario
|
||
list here is static copy, kept in sync by hand with the brochure draft
|
||
until the Bundle/BundleService models + request-access integration are
|
||
built."""
|
||
return templates.TemplateResponse(
|
||
"bundles.html",
|
||
{
|
||
"request": request,
|
||
"user": user,
|
||
"max_active_services": MAX_ACTIVE_SERVICES_PER_USER,
|
||
"idle_timeout_min": SESSION_IDLE_SECONDS // 60,
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/admin", response_class=HTMLResponse)
|
||
def admin_page(request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
users = db.scalars(select(User).order_by(User.id)).all()
|
||
categories = db.scalars(select(Category).order_by(Category.name)).all()
|
||
services = db.scalars(select(Service).where(Service.type.in_([ServiceType.WEB, ServiceType.RDP])).order_by(Service.id)).all()
|
||
web_services = [s for s in services if s.type == ServiceType.WEB]
|
||
rdp_services = [s for s in services if s.type == ServiceType.RDP and not s.is_pilot]
|
||
pilot_services = [s for s in services if s.is_pilot]
|
||
pilot_service_ids = {s.id for s in pilot_services}
|
||
service_category_map = {s.id: [] for s in services}
|
||
if services:
|
||
service_rows = db.execute(
|
||
select(ServiceCategory.service_id, ServiceCategory.category_id).where(
|
||
ServiceCategory.service_id.in_([s.id for s in services])
|
||
)
|
||
).all()
|
||
for service_id, category_id in service_rows:
|
||
service_category_map.setdefault(service_id, []).append(category_id)
|
||
acl_rows = db.scalars(select(UserServiceAccess)).all()
|
||
acl = {}
|
||
for row in acl_rows:
|
||
acl.setdefault(row.user_id, []).append(row.service_id)
|
||
for user_id in acl:
|
||
acl[user_id] = sorted(acl[user_id])
|
||
pool_status = {s.id: get_pool_status_for_service(s) for s in services}
|
||
service_health = {}
|
||
for sid, st in pool_status.items():
|
||
service_health[sid] = {
|
||
"health": st["health"],
|
||
"running": st["running"],
|
||
"desired": st["desired"],
|
||
"active_sessions": get_active_sessions_count(db, sid),
|
||
}
|
||
web_pool = get_web_pool_status()
|
||
web_totals = {
|
||
"services": len(web_services),
|
||
"running": web_pool["running"],
|
||
"desired": web_pool["desired"],
|
||
"active_sessions": sum(service_health[s.id]["active_sessions"] for s in web_services),
|
||
}
|
||
recent_sessions = db.execute(
|
||
text(
|
||
"""
|
||
SELECT s.id, u.username, sv.name AS service_name, sv.slug AS service_slug,
|
||
s.status, s.created_at, s.last_access_at
|
||
FROM sessions s
|
||
JOIN users u ON u.id = s.user_id
|
||
JOIN services sv ON sv.id = s.service_id
|
||
WHERE sv.type IN ('WEB','RDP')
|
||
ORDER BY s.created_at DESC
|
||
LIMIT 200
|
||
"""
|
||
)
|
||
).mappings().all()
|
||
open_stats = db.execute(
|
||
text(
|
||
"""
|
||
SELECT u.username, sv.name AS service_name, sv.slug AS service_slug, COUNT(*) AS opens
|
||
FROM sessions s
|
||
JOIN users u ON u.id = s.user_id
|
||
JOIN services sv ON sv.id = s.service_id
|
||
WHERE sv.type IN ('WEB','RDP')
|
||
GROUP BY u.username, sv.name, sv.slug
|
||
ORDER BY opens DESC, u.username ASC
|
||
LIMIT 200
|
||
"""
|
||
)
|
||
).mappings().all()
|
||
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
|
||
online_sessions = db.execute(
|
||
text(
|
||
"""
|
||
SELECT s.id, u.username, sv.name AS service_name, sv.slug AS service_slug,
|
||
sv.type AS service_type, s.container_id, s.created_at, s.last_access_at
|
||
FROM sessions s
|
||
JOIN users u ON u.id = s.user_id
|
||
JOIN services sv ON sv.id = s.service_id
|
||
WHERE s.status = 'ACTIVE'
|
||
AND s.last_access_at >= :cutoff
|
||
AND sv.type IN ('WEB','RDP')
|
||
ORDER BY s.last_access_at DESC, s.created_at DESC
|
||
LIMIT 500
|
||
"""
|
||
),
|
||
{"cutoff": cutoff},
|
||
).mappings().all()
|
||
rdp_slots: dict[int, list] = {}
|
||
for svc in rdp_services + pilot_services:
|
||
slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == svc.id).order_by(RdpSlot.id)).all()
|
||
slot_list = []
|
||
for slot in slots:
|
||
active_sess = db.scalar(
|
||
select(SessionModel).where(
|
||
SessionModel.container_id == f"RDPSLOT:{slot.id}",
|
||
SessionModel.status == SessionStatus.ACTIVE,
|
||
)
|
||
)
|
||
running = False
|
||
if slot.container_name:
|
||
try:
|
||
c = docker_client().containers.get(slot.container_name)
|
||
running = c.status == "running"
|
||
except Exception:
|
||
pass
|
||
occupied_username = None
|
||
if active_sess:
|
||
u = db.get(User, active_sess.user_id)
|
||
occupied_username = u.username if u else f"id={active_sess.user_id}"
|
||
assigned_username = None
|
||
assigned_expires_at = None
|
||
if slot.assigned_user_id:
|
||
au = db.get(User, slot.assigned_user_id)
|
||
assigned_username = au.username if au else f"id={slot.assigned_user_id}"
|
||
access_row = db.scalar(
|
||
select(UserServiceAccess).where(
|
||
UserServiceAccess.user_id == slot.assigned_user_id,
|
||
UserServiceAccess.service_id == svc.id,
|
||
)
|
||
)
|
||
if access_row and access_row.expires_at:
|
||
assigned_expires_at = access_row.expires_at.isoformat()
|
||
slot_list.append({
|
||
"id": slot.id,
|
||
"rdp_username": slot.rdp_username,
|
||
"container_name": slot.container_name or "",
|
||
"running": running,
|
||
"occupied_username": occupied_username,
|
||
"assigned_user_id": slot.assigned_user_id,
|
||
"assigned_username": assigned_username,
|
||
"assigned_expires_at": assigned_expires_at,
|
||
})
|
||
rdp_slots[svc.id] = slot_list
|
||
return templates.TemplateResponse(
|
||
"admin.html",
|
||
{
|
||
"request": request,
|
||
"admin": admin,
|
||
"users": users,
|
||
"users_json": [
|
||
{"id": u.id, "label": (f"{u.first_name} {u.last_name}".strip() or u.username) + f" ({u.username})"}
|
||
for u in users
|
||
],
|
||
"web_services": web_services,
|
||
"rdp_services": rdp_services,
|
||
"pilot_services": pilot_services,
|
||
"pilot_service_ids": pilot_service_ids,
|
||
"services": services,
|
||
"categories": categories,
|
||
"service_category_map": service_category_map,
|
||
"acl": acl,
|
||
"pool_status": pool_status,
|
||
"service_health": service_health,
|
||
"web_totals": web_totals,
|
||
"web_pool_size": WEB_POOL_SIZE,
|
||
"web_pool_buffer": WEB_POOL_BUFFER,
|
||
"recent_sessions": recent_sessions,
|
||
"open_stats": open_stats,
|
||
"online_sessions": online_sessions,
|
||
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
|
||
"max_active_services_per_user": MAX_ACTIVE_SERVICES_PER_USER,
|
||
"rdp_slots": rdp_slots,
|
||
},
|
||
)
|
||
|
||
|
||
|
||
|
||
|
||
@app.get("/yandex_b847b9b35f967fcc.html", include_in_schema=False)
|
||
def yandex_verify():
|
||
from fastapi.responses import HTMLResponse
|
||
return HTMLResponse('''<html>
|
||
<head>
|
||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||
</head>
|
||
<body>Verification: b847b9b35f967fcc</body>
|
||
</html>''')
|
||
|
||
|
||
@app.get("/privacy", include_in_schema=False)
|
||
def privacy_page():
|
||
from fastapi.responses import RedirectResponse
|
||
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 = pending.portal_url or PORTAL_URL
|
||
|
||
if approve_match:
|
||
days = int(approve_match.group(1))
|
||
password = _generate_password()
|
||
username = pending.email
|
||
|
||
expires = _dt2.datetime.now(_dt2.timezone.utc) + _dt2.timedelta(days=days)
|
||
parts = pending.name.strip().split(None, 1)
|
||
|
||
existing_user = db.scalar(select(User).where(User.username == username))
|
||
is_renewal = existing_user is not None
|
||
|
||
if is_renewal:
|
||
password = None
|
||
existing_user.expires_at = expires
|
||
existing_user.active = True
|
||
target_user = existing_user
|
||
db.flush()
|
||
else:
|
||
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()
|
||
target_user = new_user
|
||
|
||
# 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()
|
||
existing_svc_ids = (
|
||
{a.service_id for a in db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == target_user.id)).all()}
|
||
if is_renewal else set()
|
||
)
|
||
for svc in matched:
|
||
if svc.id not in existing_svc_ids:
|
||
db.add(UserServiceAccess(user_id=target_user.id, service_id=svc.id))
|
||
|
||
db.commit()
|
||
|
||
# send approval email
|
||
products_html = ""
|
||
if products:
|
||
items = "".join(f"<li>{escape(p)}</li>" for p in products)
|
||
products_html = f"<p style='margin:16px 0 6px;color:#45566e'><b>Предоставлен доступ к продуктам:</b></p><ul style='margin:0;padding-left:20px;color:#45566e'>{items}</ul>"
|
||
|
||
html_email = f"""<!DOCTYPE html>
|
||
<html lang="ru"><head><meta charset="utf-8"/></head>
|
||
<body style="margin:0;padding:0;background:#eef1f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
|
||
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 20px">
|
||
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e3e8ee">
|
||
<tr><td style="padding:32px 36px 0">
|
||
<img src="cid:mont_logo" alt="MONT" width="160" height="46" style="display:block;margin-bottom:24px;border:0"/>
|
||
<h1 style="margin:0 0 8px;font-size:22px;color:#14233a">Доступ к Инфраструктурному полигону MONT</h1>
|
||
<p style="margin:0 0 24px;color:#7c8ba0;font-size:14px">Ваш запрос одобрен</p>
|
||
<hr style="border:none;border-top:1px solid #e7ebf0;margin:0 0 24px"/>
|
||
</td></tr>
|
||
<tr><td style="padding:0 36px">
|
||
<p style="color:#45566e;font-size:15px;line-height:1.6;margin:0 0 20px">Здравствуйте, <b>{escape(pending.name)}</b>!<br>
|
||
Вам предоставлен доступ к полигону на <b>{days} {'день' if days==1 else 'дня' if days<5 else 'дней'}</b>.</p>
|
||
<table width="100%" cellpadding="12" cellspacing="0" style="background:#f7f9fb;border-radius:10px;border:1px solid #e7ebf0">
|
||
<tr><td style="color:#7c8ba0;font-size:13px;width:40%">Адрес портала</td>
|
||
<td><a href="{portal_url}" style="color:#1a5db5;font-size:14px">{portal_url}</a></td></tr>
|
||
<tr><td style="color:#7c8ba0;font-size:13px;border-top:1px solid #e7ebf0">Логин</td>
|
||
<td style="border-top:1px solid #e7ebf0;color:#14233a;font-size:14px;font-family:monospace">{username}</td></tr>
|
||
<tr><td style="color:#7c8ba0;font-size:13px;border-top:1px solid #e7ebf0">Пароль</td>
|
||
<td style="border-top:1px solid #e7ebf0;color:#14233a;font-size:14px;font-family:monospace">{password}</td></tr>
|
||
<tr><td style="color:#7c8ba0;font-size:13px;border-top:1px solid #e7ebf0">Доступ до</td>
|
||
<td style="border-top:1px solid #e7ebf0;color:#14233a;font-size:14px">{expires.strftime('%d.%m.%Y')}</td></tr>
|
||
</table>
|
||
{products_html}
|
||
<div style="margin:28px 0">
|
||
<a href="{portal_url}" style="display:inline-block;padding:12px 28px;background:#1a5db5;color:#fff;text-decoration:none;border-radius:8px;font-size:15px;font-weight:600">Войти в полигон</a>
|
||
</div>
|
||
</td></tr>
|
||
<tr><td style="padding:20px 36px 28px;color:#8b98ab;font-size:12px;border-top:1px solid #e7ebf0;line-height:1.6">
|
||
Если у вас возникли вопросы, свяжитесь с вашим менеджером MONT или напишите на <a href="mailto:RGalyaviev@mont.ru" style="color:#1a5db5">RGalyaviev@mont.ru</a>
|
||
</td></tr>
|
||
</table></td></tr></table>
|
||
</body></html>"""
|
||
|
||
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"""<!DOCTYPE html>
|
||
<html lang="ru"><head><meta charset="utf-8"/></head>
|
||
<body style="margin:0;padding:0;background:#eef1f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
|
||
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 20px">
|
||
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e3e8ee">
|
||
<tr><td style="padding:32px 36px 0">
|
||
<img src="cid:mont_logo" alt="MONT" width="160" height="46" style="display:block;margin-bottom:24px;border:0"/>
|
||
<h1 style="margin:0 0 8px;font-size:22px;color:#14233a">Запрос на доступ к полигону MONT</h1>
|
||
<hr style="border:none;border-top:1px solid #e7ebf0;margin:16px 0 24px"/>
|
||
</td></tr>
|
||
<tr><td style="padding:0 36px">
|
||
<p style="color:#45566e;font-size:15px;line-height:1.7;margin:0 0 20px">Здравствуйте, <b>{escape(pending.name)}</b>!<br><br>
|
||
К сожалению, на данный момент мы не можем предоставить доступ к полигону.</p>
|
||
<p style="color:#45566e;font-size:15px;line-height:1.7;margin:0 0 24px">
|
||
Для уточнения деталей, пожалуйста, свяжитесь с <b>{escape(manager_contact)}</b>.<br>
|
||
Если вы не знаете, кто ваш менеджер, напишите нам на <a href="mailto:RGalyaviev@mont.ru" style="color:#1a5db5">RGalyaviev@mont.ru</a> — мы поможем.</p>
|
||
</td></tr>
|
||
<tr><td style="padding:20px 36px 28px;color:#8b98ab;font-size:12px;border-top:1px solid #e7ebf0">
|
||
С уважением, команда MONT
|
||
</td></tr>
|
||
</table></td></tr></table>
|
||
</body></html>"""
|
||
|
||
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.api_route("/favicon.ico", methods=["GET", "HEAD"], include_in_schema=False)
|
||
def favicon():
|
||
from fastapi.responses import FileResponse
|
||
return FileResponse("static/favicon.ico", media_type="image/x-icon")
|
||
|
||
@app.get("/product/{slug}", response_class=HTMLResponse)
|
||
def product_page(slug: str, request: Request, db: Session = Depends(get_db)):
|
||
"""Public, unauthenticated SEO landing page for a single catalog
|
||
product - one per active Service row, keyed by slug. There is no
|
||
separate "generation" step: as soon as a service is saved active in
|
||
the admin panel its page is reachable here, and sitemap_xml() below
|
||
picks it up on its next request too. Mirrors the login page's public
|
||
"request access" flow (same /api/request-access endpoint + modal),
|
||
just pre-scoped to this one product.
|
||
"""
|
||
service = db.scalar(
|
||
select(Service).where(Service.slug == slug, Service.active == True)
|
||
)
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="Продукт не найден")
|
||
|
||
other_services = db.scalars(
|
||
select(Service)
|
||
.where(Service.active == True, Service.id != service.id)
|
||
.order_by(Service.name)
|
||
).all()
|
||
|
||
svc_categories = db.scalars(
|
||
select(Category)
|
||
.join(ServiceCategory, ServiceCategory.category_id == Category.id)
|
||
.where(ServiceCategory.service_id == service.id)
|
||
.order_by(Category.name)
|
||
).all()
|
||
|
||
canonical_url = f"{PORTAL_URL}/product/{service.slug}"
|
||
meta_source = service.seo_description or service.comment or ""
|
||
# Drop a leading '# Title' markdown heading line (redundant with the
|
||
# page's own <title>/<h1>) before flattening to plain text, same as
|
||
# format_seo_description() does for the on-page HTML.
|
||
meta_lines = meta_source.strip().split("\n")
|
||
if meta_lines and re.match(r"^#\s+", meta_lines[0]):
|
||
meta_lines = meta_lines[1:]
|
||
meta_plain = re.sub(r"[#*`>\-]+", " ", "\n".join(meta_lines))
|
||
meta_plain = re.sub(r"\s+", " ", meta_plain).strip()
|
||
meta_description = (meta_plain[:157] + "…") if len(meta_plain) > 160 else meta_plain
|
||
if not meta_description:
|
||
meta_description = f"{service.name} — протестируйте продукт бесплатно на инфраструктурном полигоне MONT."
|
||
|
||
description_html = format_seo_description(service.seo_description or service.comment)
|
||
|
||
return templates.TemplateResponse(
|
||
"product.html",
|
||
{
|
||
"request": request,
|
||
"service": service,
|
||
"categories": svc_categories,
|
||
"description_html": description_html,
|
||
"other_services": other_services,
|
||
"canonical_url": canonical_url,
|
||
"meta_description": meta_description,
|
||
"portal_url": PORTAL_URL,
|
||
},
|
||
)
|
||
|
||
|
||
|
||
@app.get("/robots.txt", include_in_schema=False)
|
||
def robots_txt():
|
||
from fastapi.responses import FileResponse
|
||
return FileResponse("static/robots.txt", media_type="text/plain")
|
||
|
||
|
||
@app.get("/sitemap.xml", include_in_schema=False)
|
||
def sitemap_xml(db: Session = Depends(get_db)):
|
||
# Built from the live catalog (not a static file) so a new /product/{slug}
|
||
# page shows up here the moment its Service row is saved active, with no
|
||
# separate publish step - see product_page() above.
|
||
from fastapi.responses import Response as _XmlResponse
|
||
slugs = db.scalars(
|
||
select(Service.slug).where(Service.active == True).order_by(Service.name)
|
||
).all()
|
||
entries = [(PORTAL_URL + "/", "1.0")]
|
||
entries += [(f"{PORTAL_URL}/product/{slug}", "0.8") for slug in slugs]
|
||
url_blocks = [
|
||
f" <url>\n <loc>{loc}</loc>\n <changefreq>weekly</changefreq>\n <priority>{priority}</priority>\n </url>"
|
||
for loc, priority in entries
|
||
]
|
||
xml = (
|
||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||
+ "\n".join(url_blocks) + "\n</urlset>\n"
|
||
)
|
||
return _XmlResponse(content=xml, media_type="application/xml")
|
||
|
||
@app.get("/api/public/services-by-category")
|
||
def public_services_by_category(db: Session = Depends(get_db)):
|
||
# Pilots are invite-only - admin grants them by hand, so they must not
|
||
# be selectable from the self-service "request access" form.
|
||
services = db.execute(
|
||
select(Service).where(Service.active == True, Service.is_pilot == False).order_by(Service.name)
|
||
).scalars().all()
|
||
categories = db.execute(select(Category).order_by(Category.name)).scalars().all()
|
||
cat_map = {c.id: c.name for c in categories}
|
||
|
||
svc_cats: dict[int, list[str]] = {}
|
||
links = db.execute(select(ServiceCategory)).scalars().all()
|
||
for lnk in links:
|
||
svc_cats.setdefault(lnk.service_id, []).append(cat_map.get(lnk.category_id, ""))
|
||
|
||
result: dict[str, list[dict]] = {"Без категории": []}
|
||
for svc in services:
|
||
cats = svc_cats.get(svc.id, [])
|
||
entry = {"id": svc.id, "name": svc.name}
|
||
if cats:
|
||
for cat in cats:
|
||
result.setdefault(cat, []).append(entry)
|
||
else:
|
||
result["Без категории"].append(entry)
|
||
if not result["Без категории"]:
|
||
del result["Без категории"]
|
||
return result
|
||
|
||
|
||
_public_form_attempts: dict = {}
|
||
_public_form_lock = threading.Lock()
|
||
_PUBLIC_FORM_MAX = 5 # заявок/сообщений
|
||
_PUBLIC_FORM_WINDOW = 600 # за 10 минут с одного IP
|
||
_PUBLIC_FORM_BLOCK = 1800 # затем блок на 30 минут
|
||
|
||
def _public_form_rate_limited(bucket: str, ip: str) -> bool:
|
||
"""Простой лимитер на публичные формы портала (заявка на доступ,
|
||
форма обратной связи) - защита от скриптов, которые заваливают
|
||
Telegram/почту мусорными/атакующими payload'ами (form-спам,
|
||
XSS/SQLi-пробы и т.п.). `bucket` разделяет счётчики между формами,
|
||
чтобы спам по одной не блокировал другую."""
|
||
key = f"{bucket}:{ip}"
|
||
now = time.monotonic()
|
||
with _public_form_lock:
|
||
entry = _public_form_attempts.get(key)
|
||
if entry and entry["blocked_until"] > now:
|
||
return True
|
||
if entry and now - entry["first"] > _PUBLIC_FORM_WINDOW:
|
||
entry = None
|
||
if not entry:
|
||
_public_form_attempts[key] = {"count": 1, "first": now, "blocked_until": 0.0}
|
||
return False
|
||
entry["count"] += 1
|
||
if entry["count"] > _PUBLIC_FORM_MAX:
|
||
entry["blocked_until"] = now + _PUBLIC_FORM_BLOCK
|
||
return True
|
||
return False
|
||
|
||
|
||
@app.post("/api/request-access")
|
||
async def request_access(request: Request, db: Session = Depends(get_db)):
|
||
ip_for_limit = _get_real_ip(request)
|
||
if _public_form_rate_limited("request-access", ip_for_limit):
|
||
raise HTTPException(status_code=429, detail="Слишком много заявок с вашего адреса. Попробуйте позже.")
|
||
|
||
try:
|
||
data = await request.json()
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||
|
||
name = str(data.get("name", "")).strip()[:200]
|
||
company = str(data.get("company", "")).strip()[:200]
|
||
email = str(data.get("email", "")).strip()[:254]
|
||
phone = str(data.get("phone", "")).strip()[:32]
|
||
manager = str(data.get("manager", "")).strip()[:200]
|
||
products_raw = data.get("products", [])
|
||
products = [str(p).strip()[:100] for p in products_raw[:200]] if isinstance(products_raw, list) else []
|
||
|
||
import re as _re
|
||
if not name or not company or not email or not phone:
|
||
raise HTTPException(status_code=422, detail="Заполните все обязательные поля")
|
||
if not _re.match(r'^[^\s@]+@[^\s@]+\.[^\s@]+$', email):
|
||
raise HTTPException(status_code=422, detail="Некорректный email")
|
||
if not _re.match(r'^[\+\d][\d\s\-\(\)]{6,18}$', phone):
|
||
raise HTTPException(status_code=422, detail="Некорректный номер телефона")
|
||
|
||
import html as _html
|
||
def _e(s): return _html.escape(str(s))
|
||
|
||
products_text = ""
|
||
if products:
|
||
items = "\n".join(f" • {_e(p)}" for p in products)
|
||
products_text = f"\n\n🖥 <b>Интересующие продукты:</b>\n{items}"
|
||
|
||
divider = "━━━━━━━━━━━━━━━━━━━━━━"
|
||
manager_text = f"\n🤝 <b>Менеджер MONT:</b> {_e(manager)}" if manager else ""
|
||
text = (
|
||
f"🔔 <b>Новый запрос доступа к полигону MONT</b>\n"
|
||
f"{divider}\n\n"
|
||
f"👤 <b>Имя:</b> {_e(name)}\n"
|
||
f"🏢 <b>Компания:</b> {_e(company)}\n"
|
||
f"📧 <b>Email:</b> {_e(email)}\n"
|
||
f"📱 <b>Телефон:</b> {_e(phone)}"
|
||
f"{manager_text}"
|
||
f"{products_text}"
|
||
)
|
||
|
||
log_event("ip_headers", xff=request.headers.get("x-forwarded-for","–"), xri=request.headers.get("x-real-ip","–"), client=str(request.client.host if request.client else "–"))
|
||
ip = _get_real_ip(request)
|
||
geo = _get_geo(ip)
|
||
geo_text = ""
|
||
if geo:
|
||
geo_text += "\n📍 <b>Местоположение:</b> " + _e(geo)
|
||
geo_text += "\n🖥 <b>IP:</b> " + _e(ip)
|
||
text += geo_text
|
||
|
||
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
||
log_event("telegram_not_configured")
|
||
return {"ok": True}
|
||
|
||
# save pending request
|
||
import json as _j2
|
||
req_id = _secrets.token_urlsafe(8)[:12]
|
||
_origin = request.headers.get('origin', '')
|
||
if 'stand.mont.ru' in _origin:
|
||
_req_portal_url = 'https://stand.mont.ru'
|
||
else:
|
||
_req_portal_url = PORTAL_URL
|
||
pending = PendingAccessRequest(
|
||
id=req_id, name=name, company=company, email=email,
|
||
phone=phone, manager=manager,
|
||
products_json=_j2.dumps(products, ensure_ascii=False),
|
||
portal_url=_req_portal_url,
|
||
telegram_message=text,
|
||
)
|
||
db.add(pending)
|
||
db.commit()
|
||
|
||
try:
|
||
_tg_api("sendMessage", {
|
||
"chat_id": TELEGRAM_CHAT_ID,
|
||
"text": text,
|
||
"parse_mode": "HTML",
|
||
"reply_markup": _make_approval_keyboard(req_id),
|
||
})
|
||
pending.telegram_notified = True
|
||
db.commit()
|
||
except Exception as e:
|
||
log_event("telegram_send_error", error=str(e))
|
||
# not fatal here - _telegram_notify_retry_loop() will keep retrying
|
||
# every 5 minutes (telegram_notified stays False) until it goes through
|
||
|
||
# second, independent approval channel - failures here must never affect
|
||
# the Telegram channel above (already sent) or the API response below
|
||
_send_admin_decision_email(pending)
|
||
|
||
return {"ok": True}
|
||
|
||
|
||
|
||
|
||
# /api/contact ("написать Руслану") was removed 2026-08-10: the UI entry
|
||
# point (#btn-contact-ruslan button in login.html) was already taken off the
|
||
# page earlier, but the route itself stayed registered and reachable by
|
||
# anyone calling it directly - which is exactly what an attacker was doing.
|
||
# Not registering the route at all means FastAPI returns a plain 404 for it
|
||
# without running any app code. If this form is ever brought back, restore
|
||
# it from git history / main.py.bak.* on the server instead of re-adding a
|
||
# half-remembered version here.
|
||
|
||
def _notify_pending_request(pending_id: str, text: str) -> None:
|
||
"""Send the Telegram + email approval notifications for a pending
|
||
request in the background, after the HTTP response has already gone
|
||
out - both calls are external network I/O (Telegram API, SMTP) and have
|
||
nothing to do with whether the request was accepted."""
|
||
from database import SessionLocal
|
||
db2 = SessionLocal()
|
||
try:
|
||
pending = db2.get(PendingAccessRequest, pending_id)
|
||
if not pending:
|
||
return
|
||
try:
|
||
_tg_api("sendMessage", {
|
||
"chat_id": TELEGRAM_CHAT_ID,
|
||
"text": text,
|
||
"parse_mode": "HTML",
|
||
"reply_markup": _make_approval_keyboard(pending_id),
|
||
})
|
||
pending.telegram_notified = True
|
||
db2.commit()
|
||
except Exception as e:
|
||
log_event("telegram_send_error", error=str(e))
|
||
# not fatal - _telegram_notify_retry_loop() keeps retrying every 5 min
|
||
_send_admin_decision_email(pending)
|
||
finally:
|
||
db2.close()
|
||
|
||
|
||
@app.post("/api/request-more-access")
|
||
async def request_more_access(
|
||
request: Request,
|
||
background_tasks: BackgroundTasks,
|
||
user: User = Depends(require_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""A logged-in user asking for additional products beyond what they
|
||
already have. Deliberately reuses the exact same pending-request /
|
||
Telegram-approval / _apply_access_decision pipeline as the public
|
||
"Запросить доступ" flow on the login page - approving it renews the
|
||
user's expires_at *and* grants the newly-requested services without
|
||
touching what they already have (see _apply_access_decision). We skip
|
||
company/phone here since the account already identifies the requester;
|
||
PendingAccessRequest.company/phone just get an explanatory placeholder
|
||
so it reads clearly in Telegram/email, not a real company/phone value."""
|
||
validate_csrf(request)
|
||
|
||
if _public_form_rate_limited("request-more-access", f"user:{user.id}"):
|
||
raise HTTPException(status_code=429, detail="Слишком много заявок подряд. Попробуйте позже.")
|
||
|
||
try:
|
||
data = await request.json()
|
||
except Exception:
|
||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||
|
||
products_raw = data.get("products", [])
|
||
requested = [str(p).strip()[:200] for p in products_raw[:200]] if isinstance(products_raw, list) else []
|
||
requested = [p for p in requested if p]
|
||
note = str(data.get("note", "")).strip()[:500]
|
||
|
||
if not requested:
|
||
raise HTTPException(status_code=422, detail="Выберите хотя бы один продукт")
|
||
|
||
already_granted = {
|
||
row[0].lower()
|
||
for row in db.execute(
|
||
select(Service.name)
|
||
.join(UserServiceAccess, UserServiceAccess.service_id == Service.id)
|
||
.where(UserServiceAccess.user_id == user.id)
|
||
).all()
|
||
}
|
||
from sqlalchemy import func as _func4
|
||
# is_pilot excluded even if the caller bypasses the UI and posts a pilot's
|
||
# name directly - pilots are admin-granted only, never self-service.
|
||
matched = db.scalars(
|
||
select(Service).where(
|
||
_func4.lower(Service.name).in_([p.lower() for p in requested]),
|
||
Service.active == True,
|
||
Service.is_pilot == False,
|
||
)
|
||
).all()
|
||
products = [svc.name for svc in matched if svc.name.lower() not in already_granted]
|
||
if not products:
|
||
raise HTTPException(status_code=422, detail="Эти продукты уже доступны или не найдены в каталоге")
|
||
|
||
display_name = (f"{user.first_name} {user.last_name}".strip()) or user.username
|
||
|
||
def _e(s):
|
||
import html as _html_local
|
||
return _html_local.escape(str(s))
|
||
|
||
items = "\n".join(f" • {_e(p)}" for p in products)
|
||
note_text = f"\n\n💬 <b>Комментарий:</b> {_e(note)}" if note else ""
|
||
divider = "━━━━━━━━━━━━━━━━━━━━━━"
|
||
text = (
|
||
f"🔔 <b>Запрос дополнительного доступа</b>\n"
|
||
f"{divider}\n\n"
|
||
f"👤 <b>Пользователь:</b> {_e(display_name)} ({_e(user.username)})\n"
|
||
f"🖥 <b>Запрошенные продукты:</b>\n{items}"
|
||
f"{note_text}"
|
||
)
|
||
|
||
ip = _get_real_ip(request)
|
||
geo = _get_geo(ip)
|
||
geo_text = ""
|
||
if geo:
|
||
geo_text += "\n📍 <b>Местоположение:</b> " + _e(geo)
|
||
geo_text += "\n🖥 <b>IP:</b> " + _e(ip)
|
||
text += geo_text
|
||
|
||
req_id = _secrets.token_urlsafe(8)[:12]
|
||
origin = request.headers.get("origin", "")
|
||
portal_url = "https://stand.mont.ru" if "stand.mont.ru" in origin else PORTAL_URL
|
||
pending = PendingAccessRequest(
|
||
id=req_id,
|
||
name=display_name,
|
||
company="Существующий пользователь портала",
|
||
email=user.username,
|
||
phone="",
|
||
manager="",
|
||
products_json=__import__("json").dumps(products, ensure_ascii=False),
|
||
portal_url=portal_url,
|
||
telegram_message=text,
|
||
)
|
||
db.add(pending)
|
||
db.commit()
|
||
|
||
if TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
|
||
background_tasks.add_task(_notify_pending_request, req_id, text)
|
||
else:
|
||
log_event("telegram_not_configured")
|
||
|
||
return {"ok": True}
|
||
|
||
|
||
|
||
@app.post("/login")
|
||
def login(
|
||
request: Request,
|
||
username: str = Form(...),
|
||
password: str = Form(...),
|
||
csrf_token: str = Form(...),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
cookie_csrf = request.cookies.get(CSRF_COOKIE)
|
||
if not cookie_csrf or csrf_token != cookie_csrf:
|
||
raise HTTPException(status_code=403, detail="CSRF failed")
|
||
|
||
ip = _get_real_ip(request)
|
||
if check_login_rate_limit(ip):
|
||
raise HTTPException(status_code=429, detail="Слишком много попыток входа. Попробуйте через 15 минут.")
|
||
|
||
user = db.scalar(select(User).where(User.username == username))
|
||
if not user or not verify_password(password, user.password_hash):
|
||
record_login_failure(ip)
|
||
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
||
response = templates.TemplateResponse(
|
||
"login.html",
|
||
{
|
||
"request": request,
|
||
"csrf_token": csrf,
|
||
"login_error": "Неверный логин или пароль",
|
||
"session_notice": "",
|
||
"public_services": _login_wall_services(db),
|
||
"public_pilots": _login_wall_pilots(db),
|
||
},
|
||
status_code=401,
|
||
)
|
||
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="lax", path="/")
|
||
return response
|
||
if not user_is_valid(user):
|
||
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
||
response = templates.TemplateResponse(
|
||
"login.html",
|
||
{
|
||
"request": request,
|
||
"csrf_token": csrf,
|
||
"login_error": "Доступ к сервису приостоновлен, обратитесь к вашему менеджеру",
|
||
"session_notice": "",
|
||
"public_services": _login_wall_services(db),
|
||
"public_pilots": _login_wall_pilots(db),
|
||
},
|
||
status_code=403,
|
||
)
|
||
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="lax", path="/")
|
||
return response
|
||
|
||
record_login_success(ip)
|
||
response = RedirectResponse(url="/", status_code=303)
|
||
issue_auth_cookie(response, user)
|
||
issue_csrf_cookie(response)
|
||
audit(db, "LOGIN", f"login success: {username}", user_id=user.id)
|
||
return response
|
||
|
||
|
||
@app.post("/logout")
|
||
def logout(request: Request):
|
||
response = RedirectResponse(url="/", status_code=303)
|
||
response.delete_cookie(COOKIE_NAME, path="/")
|
||
response.delete_cookie(CSRF_COOKIE, path="/")
|
||
return response
|
||
|
||
|
||
@app.get("/go/{slug}")
|
||
def go_service(
|
||
slug: str,
|
||
sw: Optional[int] = Query(default=None, ge=320, le=7680),
|
||
sh: Optional[int] = Query(default=None, ge=240, le=4320),
|
||
user: User = Depends(require_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
total_started = time.perf_counter()
|
||
phase_ms = {}
|
||
|
||
def _mark(name: str, started: float) -> None:
|
||
phase_ms[name] = int((time.perf_counter() - started) * 1000)
|
||
|
||
def _emit(result: str, **extra) -> None:
|
||
payload = {
|
||
"user_id": user.id,
|
||
"service_slug": slug,
|
||
"result": result,
|
||
"total_ms": int((time.perf_counter() - total_started) * 1000),
|
||
}
|
||
payload.update(phase_ms)
|
||
payload.update(extra)
|
||
log_event("go_service_timing", **payload)
|
||
|
||
log_event("session_open_requested", user_id=user.id, service_slug=slug, sw=sw, sh=sh)
|
||
service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="Service not found")
|
||
if service.type == ServiceType.VNC:
|
||
raise HTTPException(status_code=410, detail="VNC services are deprecated")
|
||
if not has_access(db, user.id, service.id):
|
||
raise HTTPException(status_code=403, detail="ACL denied")
|
||
|
||
client_width, client_height = sanitize_client_resolution(sw, sh)
|
||
log_event(
|
||
"session_open_resolution",
|
||
user_id=user.id,
|
||
service_slug=slug,
|
||
sw=sw,
|
||
sh=sh,
|
||
client_width=client_width,
|
||
client_height=client_height,
|
||
)
|
||
|
||
user_lock_started = time.perf_counter()
|
||
try:
|
||
with allocator_lock(db, 92000 + int(user.id), timeout_seconds=GO_USER_LOCK_TIMEOUT_SECONDS):
|
||
_mark("wait_user_lock_ms", user_lock_started)
|
||
|
||
t_existing = time.perf_counter()
|
||
existing_user_session = find_active_session_for_user_service(db, user.id, service.id)
|
||
_mark("check_existing_ms", t_existing)
|
||
if existing_user_session:
|
||
_emit("reuse_session", session_id=existing_user_session.id)
|
||
if existing_user_session.container_id and existing_user_session.container_id.startswith("RDPSLOT:"):
|
||
try:
|
||
_rdp_slot_id = int(existing_user_session.container_id.split(":", 1)[1])
|
||
threading.Thread(target=connect_rdp_slot, args=(_rdp_slot_id,), daemon=True).start()
|
||
except Exception:
|
||
pass
|
||
return RedirectResponse(url=session_redirect_url(existing_user_session), status_code=303)
|
||
|
||
t_limit = time.perf_counter()
|
||
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
|
||
active_rows = db.scalars(
|
||
select(SessionModel).where(
|
||
SessionModel.user_id == user.id,
|
||
SessionModel.status == SessionStatus.ACTIVE,
|
||
SessionModel.last_access_at >= cutoff,
|
||
)
|
||
).all()
|
||
active_rows = sorted(active_rows, key=lambda row: row.created_at)
|
||
active_service_ids = {row.service_id for row in active_rows}
|
||
_mark("check_limit_ms", t_limit)
|
||
if service.id not in active_service_ids and len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER:
|
||
oldest = next((row for row in active_rows if row.service_id != service.id), None)
|
||
if oldest:
|
||
t_rotate = time.perf_counter()
|
||
terminate_session_record(db, oldest, SessionStatus.ROTATED, stop_container=True)
|
||
db.commit()
|
||
_mark("rotate_oldest_ms", t_rotate)
|
||
log_event(
|
||
"session_rotated",
|
||
user_id=user.id,
|
||
closed_session_id=oldest.id,
|
||
closed_service_id=oldest.service_id,
|
||
new_service_id=service.id,
|
||
)
|
||
else:
|
||
_emit("max_services_redirect")
|
||
return RedirectResponse(url="/?launch_error=max_services", status_code=303)
|
||
|
||
if service.type == ServiceType.RDP:
|
||
t_rdp_slots = time.perf_counter()
|
||
slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == service.id)).all()
|
||
_mark("check_rdp_slots_ms", t_rdp_slots)
|
||
if slots:
|
||
session_id = str(uuid.uuid4())
|
||
try:
|
||
with allocator_lock(db, 91003, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
|
||
busy_slot_ids: set[int] = set()
|
||
for row in db.scalars(
|
||
select(SessionModel).where(
|
||
SessionModel.status == SessionStatus.ACTIVE,
|
||
SessionModel.service_id == service.id,
|
||
SessionModel.container_id.like("RDPSLOT:%"),
|
||
)
|
||
).all():
|
||
try:
|
||
busy_slot_ids.add(int(row.container_id.split(":", 1)[1]))
|
||
except Exception:
|
||
pass
|
||
if service.is_pilot:
|
||
# Pilot slots are reserved per person (admin
|
||
# assigns the container in advance), never
|
||
# picked from a shared pool - the earlier
|
||
# existing_user_session check above already
|
||
# resumes an active session on this slot, so
|
||
# reaching here with it "busy" would mean two
|
||
# concurrent launches; treat that the same as
|
||
# "no slot available" rather than silently
|
||
# handing the user a different pilot's machine.
|
||
free_slot = next(
|
||
(s for s in slots if s.assigned_user_id == user.id and s.id not in busy_slot_ids),
|
||
None,
|
||
)
|
||
if not free_slot:
|
||
_emit("pilot_slot_not_assigned")
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Вам не назначен слот для этого пилота. Обратитесь к администратору.",
|
||
)
|
||
else:
|
||
free_slot = next((s for s in slots if s.id not in busy_slot_ids), None)
|
||
if not free_slot:
|
||
_emit("rdp_all_slots_busy")
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail="Все слоты этого RDP сервиса заняты. Попробуйте позже.",
|
||
)
|
||
session_obj = SessionModel(
|
||
id=session_id,
|
||
user_id=user.id,
|
||
service_id=service.id,
|
||
container_id=f"RDPSLOT:{free_slot.id}",
|
||
status=SessionStatus.ACTIVE,
|
||
created_at=now_utc(),
|
||
last_access_at=now_utc(),
|
||
)
|
||
db.add(session_obj)
|
||
db.commit()
|
||
except LockTimeoutError:
|
||
_emit("rdp_slot_lock_timeout")
|
||
raise HTTPException(status_code=503, detail="Пул RDP занят. Повторите через несколько секунд.")
|
||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="rdp_slot", slot_id=free_slot.id)
|
||
audit(db, "SESSION_CREATE_RDP_SLOT", f"service={service.slug} session={session_id} slot={free_slot.id}", user_id=user.id)
|
||
_emit("session_created_rdp_slot", session_id=session_id, slot_id=free_slot.id)
|
||
threading.Thread(target=connect_rdp_slot, args=(free_slot.id,), daemon=True).start()
|
||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||
else:
|
||
# Legacy: no slots configured — exclusive single-session behaviour
|
||
active_owner = find_active_session_for_service(db, service.id)
|
||
if active_owner:
|
||
if active_owner.user_id != user.id:
|
||
_emit("rdp_busy_legacy")
|
||
raise HTTPException(status_code=503, detail="RDP сервис занят. Попробуйте позже.")
|
||
_emit("reuse_rdp_session", session_id=active_owner.id)
|
||
return RedirectResponse(url=session_redirect_url(active_owner), status_code=303)
|
||
|
||
session_id = str(uuid.uuid4())
|
||
if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0:
|
||
try:
|
||
t_pool_lock = time.perf_counter()
|
||
with allocator_lock(db, 91001, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
|
||
_mark("wait_web_pool_lock_ms", t_pool_lock)
|
||
t_ensure = time.perf_counter()
|
||
ensure_web_pool()
|
||
_mark("ensure_web_pool_ms", t_ensure)
|
||
|
||
t_acquire = time.perf_counter()
|
||
slot = acquire_web_pool_slot(db)
|
||
_mark("acquire_web_slot_ms", t_acquire)
|
||
slot_cid = f"WEBPOOLIDX:{slot}"
|
||
|
||
t_dispatch = time.perf_counter()
|
||
terminate_active_slot_sessions(db, slot_cid)
|
||
dispatch_web_pool_target(slot, service, width=client_width, height=client_height)
|
||
_mark("dispatch_web_target_ms", t_dispatch)
|
||
|
||
t_commit = time.perf_counter()
|
||
session_obj = SessionModel(
|
||
id=session_id,
|
||
user_id=user.id,
|
||
service_id=service.id,
|
||
container_id=slot_cid,
|
||
status=SessionStatus.ACTIVE,
|
||
created_at=now_utc(),
|
||
last_access_at=now_utc(),
|
||
)
|
||
db.add(session_obj)
|
||
db.commit()
|
||
_mark("db_commit_ms", t_commit)
|
||
except LockTimeoutError:
|
||
_emit("web_pool_lock_timeout")
|
||
raise HTTPException(status_code=503, detail="Пул WEB занят. Повторите через несколько секунд.")
|
||
except Exception as exc:
|
||
logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
|
||
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="web_pool", error=str(exc))
|
||
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
||
_emit("web_pool_create_failed", error=str(exc))
|
||
raise HTTPException(status_code=502, detail="WEB runtime failed to switch target")
|
||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="web_pool", slot=slot)
|
||
audit(db, "SESSION_CREATE_WEB_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
|
||
_emit("session_created_web_pool", session_id=session_id, slot=slot)
|
||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||
|
||
if service_uses_universal_pool(service):
|
||
try:
|
||
t_pool_lock = time.perf_counter()
|
||
with allocator_lock(db, 91002, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
|
||
_mark("wait_universal_pool_lock_ms", t_pool_lock)
|
||
t_ensure = time.perf_counter()
|
||
ensure_universal_pool()
|
||
_mark("ensure_universal_pool_ms", t_ensure)
|
||
|
||
t_acquire = time.perf_counter()
|
||
slot = acquire_universal_slot(db)
|
||
_mark("acquire_universal_slot_ms", t_acquire)
|
||
slot_cid = f"POOLIDX:{slot}"
|
||
|
||
t_dispatch = time.perf_counter()
|
||
terminate_active_slot_sessions(db, slot_cid)
|
||
dispatch_universal_target(slot, service, width=client_width, height=client_height)
|
||
_mark("dispatch_universal_target_ms", t_dispatch)
|
||
|
||
t_commit = time.perf_counter()
|
||
session_obj = SessionModel(
|
||
id=session_id,
|
||
user_id=user.id,
|
||
service_id=service.id,
|
||
container_id=slot_cid,
|
||
status=SessionStatus.ACTIVE,
|
||
created_at=now_utc(),
|
||
last_access_at=now_utc(),
|
||
)
|
||
db.add(session_obj)
|
||
db.commit()
|
||
_mark("db_commit_ms", t_commit)
|
||
except LockTimeoutError:
|
||
_emit("universal_pool_lock_timeout")
|
||
raise HTTPException(status_code=503, detail="Пул RDP занят. Повторите через несколько секунд.")
|
||
except Exception as exc:
|
||
logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
|
||
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="universal_pool", error=str(exc))
|
||
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
||
_emit("universal_pool_create_failed", error=str(exc))
|
||
raise HTTPException(status_code=502, detail="Universal runtime failed to switch target")
|
||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="universal_pool", slot=slot)
|
||
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
|
||
_emit("session_created_universal_pool", session_id=session_id, slot=slot)
|
||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||
|
||
if service.type == ServiceType.WEB and desired_pool_size(service) > 0:
|
||
t_warm = time.perf_counter()
|
||
ensure_warm_pool(service)
|
||
open_warm_web_url(service, service.target)
|
||
_mark("warm_pool_prepare_ms", t_warm)
|
||
|
||
t_commit = time.perf_counter()
|
||
session_obj = SessionModel(
|
||
id=session_id,
|
||
user_id=user.id,
|
||
service_id=service.id,
|
||
container_id=f"POOL:{service.slug}",
|
||
status=SessionStatus.ACTIVE,
|
||
created_at=now_utc(),
|
||
last_access_at=now_utc(),
|
||
)
|
||
db.add(session_obj)
|
||
db.commit()
|
||
_mark("db_commit_ms", t_commit)
|
||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="warm_pool")
|
||
audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id}", user_id=user.id)
|
||
_emit("session_created_warm_pool", session_id=session_id)
|
||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||
|
||
try:
|
||
t_create = time.perf_counter()
|
||
container_id = create_runtime_container(service, session_id)
|
||
_mark("create_runtime_container_ms", t_create)
|
||
except Exception as exc:
|
||
logger.exception("session_container_create_failed slug=%s user_id=%s", slug, user.id)
|
||
log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="single_runtime", error=str(exc))
|
||
audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
|
||
_emit("single_runtime_create_failed", error=str(exc))
|
||
raise HTTPException(status_code=502, detail="Session runtime failed to start")
|
||
|
||
t_commit = time.perf_counter()
|
||
session_obj = SessionModel(
|
||
id=session_id,
|
||
user_id=user.id,
|
||
service_id=service.id,
|
||
container_id=container_id,
|
||
status=SessionStatus.ACTIVE,
|
||
created_at=now_utc(),
|
||
last_access_at=now_utc(),
|
||
)
|
||
db.add(session_obj)
|
||
db.commit()
|
||
_mark("db_commit_ms", t_commit)
|
||
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="single_runtime", container_id=container_id)
|
||
|
||
audit(db, "SESSION_CREATE", f"service={service.slug} session={session_id}", user_id=user.id)
|
||
t_wait = time.perf_counter()
|
||
ready = wait_for_session_route(session_id)
|
||
_mark("wait_session_route_ms", t_wait)
|
||
log_event("session_route_ready", session_id=session_id, ready=ready)
|
||
_emit("session_created_single_runtime", session_id=session_id, ready=ready)
|
||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||
except LockTimeoutError:
|
||
_emit("user_lock_timeout")
|
||
raise HTTPException(status_code=429, detail="Слишком много параллельных запусков. Повторите через несколько секунд.")
|
||
|
||
|
||
@app.get("/svc/{slug}/", response_class=HTMLResponse)
|
||
def service_wait_page(slug: str, request: Request, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
||
service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="Service not found")
|
||
if not has_access(db, user.id, service.id):
|
||
raise HTTPException(status_code=403, detail="ACL denied")
|
||
return HTMLResponse(
|
||
content="""
|
||
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset='utf-8'>
|
||
<title>Service Starting</title>
|
||
<style>
|
||
body { font-family: sans-serif; background: #f4f6f8; display: grid; place-items: center; height: 100vh; margin: 0; color:#1b3145; }
|
||
.card { background: #fff; padding: 1rem 1.2rem; border-radius: 10px; box-shadow: 0 8px 20px rgba(0,0,0,.08); min-width: 340px; }
|
||
.title { font-weight: 700; margin-bottom: 0.5rem; }
|
||
.state { margin-bottom: 0.6rem; }
|
||
ul { margin: 0; padding-left: 1.1rem; }
|
||
li { margin: 0.2rem 0; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<div class="title">Сервис запускается</div>
|
||
<div class="state" id="state">Проверка...</div>
|
||
<ul id="steps"></ul>
|
||
</div>
|
||
<script>
|
||
const slug = window.location.pathname.replace(/^\\/svc\\//, '').replace(/\\/$/, '');
|
||
async function tick() {
|
||
const r = await fetch(`/api/services/${slug}/status`, {credentials:'include'});
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
document.getElementById('state').textContent = data.message || 'Запуск...';
|
||
const ul = document.getElementById('steps');
|
||
ul.innerHTML = '';
|
||
(data.steps || []).forEach((x) => {
|
||
const li = document.createElement('li');
|
||
li.textContent = x;
|
||
ul.appendChild(li);
|
||
});
|
||
if (data.ready) window.location.replace(`/svc/${slug}/`);
|
||
}
|
||
setInterval(tick, 1000);
|
||
tick();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
""".strip(),
|
||
status_code=200,
|
||
)
|
||
|
||
|
||
@app.get("/s/{session_id}/", response_class=HTMLResponse)
|
||
def session_wait_page(session_id: str, request: Request, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
||
sess = db.get(SessionModel, session_id)
|
||
if not sess or sess.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="Session not found")
|
||
if sess.status != SessionStatus.ACTIVE:
|
||
raise HTTPException(status_code=410, detail="Session is not active")
|
||
service = db.get(Service, sess.service_id)
|
||
service_title = service.name if service else "Сервис"
|
||
is_rdp = service and service.type == ServiceType.RDP
|
||
redirect_target = session_redirect_url(sess)
|
||
return HTMLResponse(
|
||
content=f"""<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset='utf-8'>
|
||
<title>{service_title}</title>
|
||
<style>html,body{{margin:0;height:100%;background:#0f1720}}</style>
|
||
</head>
|
||
<body>
|
||
<script>
|
||
const sessionId = "{session_id}";
|
||
async function tick() {{
|
||
try {{
|
||
const r = await fetch(`/api/sessions/${{sessionId}}/status`, {{credentials:'include'}});
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
if (data.ready) {{ window.location.replace(data.redirect_url || "{redirect_target}"); return; }}
|
||
}} catch(e) {{}}
|
||
setTimeout(tick, 300);
|
||
}}
|
||
tick();
|
||
</script>
|
||
</body>
|
||
</html>""",
|
||
status_code=200,
|
||
)
|
||
|
||
|
||
@app.get("/s/{session_id}/view", response_class=HTMLResponse)
|
||
def session_view_page(session_id: str, request: Request, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
||
sess = db.get(SessionModel, session_id)
|
||
if not sess or sess.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="Session not found")
|
||
if sess.status != SessionStatus.ACTIVE:
|
||
raise HTTPException(status_code=410, detail="Session is not active")
|
||
service = db.get(Service, sess.service_id)
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="Service not found")
|
||
iframe_src = None
|
||
if sess.container_id and sess.container_id.startswith("POOL:"):
|
||
iframe_src = f"/svc/{service.slug}/?sid={session_id}"
|
||
elif sess.container_id and sess.container_id.startswith("WEBPOOLIDX:"):
|
||
try:
|
||
slot = int(sess.container_id.split(":", 1)[1])
|
||
iframe_src = f"/w/{slot}/?sid={session_id}"
|
||
except Exception:
|
||
iframe_src = None
|
||
elif sess.container_id and sess.container_id.startswith("POOLIDX:"):
|
||
try:
|
||
slot = int(sess.container_id.split(":", 1)[1])
|
||
iframe_src = f"/u/{slot}/?sid={session_id}"
|
||
except Exception:
|
||
iframe_src = None
|
||
elif sess.container_id and sess.container_id.startswith("RDPSLOT:"):
|
||
try:
|
||
slot = int(sess.container_id.split(":", 1)[1])
|
||
iframe_src = f"/rdp/{slot}/?sid={session_id}"
|
||
except Exception:
|
||
iframe_src = None
|
||
if iframe_src:
|
||
creds_html = ""
|
||
|
||
return HTMLResponse(
|
||
content=f"""
|
||
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset='utf-8'>
|
||
<title>{service.name}</title>
|
||
<style>
|
||
html,body,iframe {{ margin:0; width:100%; height:100%; border:0; background:#0f1720; }}
|
||
.creds-panel{{
|
||
position:fixed;right:16px;top:16px;z-index:999;
|
||
background:linear-gradient(180deg,rgba(15,24,36,.88),rgba(9,14,22,.94));
|
||
border:1px solid rgba(255,255,255,.22);backdrop-filter:blur(6px);
|
||
box-shadow:0 10px 28px rgba(0,0,0,.4);padding:10px 12px 11px;border-radius:14px;
|
||
min-width:220px;max-width:320px;
|
||
}}
|
||
.creds-close{{
|
||
position:absolute;top:6px;right:8px;background:none;border:none;
|
||
color:rgba(255,255,255,.55);font-size:14px;cursor:pointer;line-height:1;padding:2px 4px;
|
||
}}
|
||
.creds-close:hover{{color:#fff}}
|
||
.cr-row{{display:flex;align-items:center;gap:6px;margin-bottom:5px;}}
|
||
.cr-label{{font:600 11px/1 sans-serif;text-transform:uppercase;letter-spacing:.04em;
|
||
color:rgba(180,210,240,.7);min-width:46px;flex-shrink:0;}}
|
||
.cr-val{{font:600 13px/1 monospace;color:#dce8f5;flex:1;overflow:hidden;
|
||
text-overflow:ellipsis;white-space:nowrap;}}
|
||
.cr-masked{{letter-spacing:.1em;font-size:14px;}}
|
||
.cr-hint{{margin:4px 0 0;font:400 11px/1.35 sans-serif;color:rgba(180,210,240,.65);}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<iframe id="app-frame" src="" allow="clipboard-read; clipboard-write" style="display:none"></iframe>{creds_html}
|
||
<script>
|
||
const sessionId = "{session_id}";
|
||
const iframeSrc = "{iframe_src}";
|
||
async function waitReady() {{
|
||
try {{
|
||
const r = await fetch(`/api/sessions/${{sessionId}}/status`, {{credentials:'include'}});
|
||
if (r.ok) {{
|
||
const data = await r.json();
|
||
if (data.ready) {{
|
||
try {{
|
||
const probe = await fetch(iframeSrc, {{method:'HEAD', credentials:'include'}});
|
||
if (probe.status >= 200 && probe.status < 300) {{
|
||
const f = document.getElementById('app-frame');
|
||
f.src = iframeSrc;
|
||
f.style.display = '';
|
||
return;
|
||
}}
|
||
}} catch(e) {{}}
|
||
}}
|
||
}}
|
||
}} catch(e) {{}}
|
||
setTimeout(waitReady, 400);
|
||
}}
|
||
waitReady();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
""".strip()
|
||
)
|
||
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||
|
||
|
||
@app.post("/api/sessions/{session_id}/touch")
|
||
def touch_session(session_id: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
||
sess = db.get(SessionModel, session_id)
|
||
if not sess or sess.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="Session not found")
|
||
if sess.status != SessionStatus.ACTIVE:
|
||
reason = session_closed_reason(sess, db)
|
||
log_event(
|
||
"session_touch_rejected",
|
||
level=logging.WARNING,
|
||
session_id=session_id,
|
||
user_id=user.id,
|
||
status=sess.status.value,
|
||
reason=reason,
|
||
)
|
||
return JSONResponse(
|
||
status_code=410,
|
||
content={
|
||
"ok": False,
|
||
"reason": reason,
|
||
"status": sess.status.value,
|
||
},
|
||
)
|
||
sess.last_access_at = now_utc()
|
||
db.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/sessions/{session_id}/close")
|
||
def close_session(session_id: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
||
sess = db.get(SessionModel, session_id)
|
||
if not sess or sess.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="Session not found")
|
||
if sess.status != SessionStatus.ACTIVE:
|
||
log_event(
|
||
"session_close_already_closed",
|
||
session_id=session_id,
|
||
user_id=user.id,
|
||
status=sess.status.value,
|
||
reason=session_closed_reason(sess, db),
|
||
)
|
||
return {"ok": True, "status": sess.status.value}
|
||
terminate_session_record(db, sess, SessionStatus.TERMINATED, stop_container=True)
|
||
db.commit()
|
||
log_event("session_closed_by_user", session_id=session_id, user_id=user.id)
|
||
return {"ok": True, "status": "TERMINATED"}
|
||
|
||
|
||
@app.get("/api/services/{slug}/status")
|
||
def service_status(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
||
service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="Service not found")
|
||
if service.type == ServiceType.VNC:
|
||
raise HTTPException(status_code=410, detail="VNC services are deprecated")
|
||
if not has_access(db, user.id, service.id):
|
||
raise HTTPException(status_code=403, detail="ACL denied")
|
||
pool = get_pool_status_for_service(service)
|
||
route_ok = route_ready(f"/svc/{slug}/")
|
||
ready = route_ok and (pool["running"] > 0 if desired_pool_size(service) > 0 else True)
|
||
steps = [
|
||
f"ACL: OK ({user.username})",
|
||
f"Пул: {pool['running']} / {pool['desired']}",
|
||
f"Маршрут /svc/{slug}/: {'OK' if route_ok else 'ожидание'}",
|
||
]
|
||
return {
|
||
"ready": ready,
|
||
"message": "Готово, открываем..." if ready else "Поднимаем контейнер и маршрут...",
|
||
"steps": steps,
|
||
}
|
||
|
||
|
||
@app.get("/api/sessions/{session_id}/status")
|
||
def session_status(session_id: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
||
sess = db.get(SessionModel, session_id)
|
||
if not sess or sess.user_id != user.id:
|
||
raise HTTPException(status_code=404, detail="Session not found")
|
||
if sess.status != SessionStatus.ACTIVE:
|
||
raise HTTPException(status_code=410, detail="Session is not active")
|
||
service = db.get(Service, sess.service_id)
|
||
pooled_web = bool(sess.container_id and sess.container_id.startswith("POOL:") and service and service.type == ServiceType.WEB)
|
||
web_pool_idx = None
|
||
universal_pool_idx = None
|
||
if sess.container_id and sess.container_id.startswith("WEBPOOLIDX:"):
|
||
try:
|
||
web_pool_idx = int(sess.container_id.split(":", 1)[1])
|
||
except Exception:
|
||
web_pool_idx = None
|
||
if sess.container_id and sess.container_id.startswith("POOLIDX:"):
|
||
try:
|
||
universal_pool_idx = int(sess.container_id.split(":", 1)[1])
|
||
except Exception:
|
||
universal_pool_idx = None
|
||
pooled_rdp = bool(sess.container_id and sess.container_id.startswith("POOL:") and service and service.type == ServiceType.RDP)
|
||
rdp_slot_idx = None
|
||
if sess.container_id and sess.container_id.startswith("RDPSLOT:"):
|
||
try:
|
||
rdp_slot_idx = int(sess.container_id.split(":", 1)[1])
|
||
except Exception:
|
||
rdp_slot_idx = None
|
||
if pooled_web and service:
|
||
route_path = f"/svc/{service.slug}/"
|
||
elif pooled_rdp and service:
|
||
route_path = f"/svc/{service.slug}/"
|
||
elif rdp_slot_idx is not None:
|
||
route_path = f"/rdp/{rdp_slot_idx}/"
|
||
else:
|
||
route_path = f"/s/{session_id}/"
|
||
if web_pool_idx is not None:
|
||
route_path = f"/w/{web_pool_idx}/"
|
||
if universal_pool_idx is not None:
|
||
route_path = f"/u/{universal_pool_idx}/"
|
||
route_ok = route_ready(route_path)
|
||
running = container_running(sess.container_id)
|
||
ready = running and route_ok
|
||
steps = [
|
||
f"Контейнер: {'running' if running else 'starting'}",
|
||
f"Маршрут {route_path}: {'OK' if route_ok else 'ожидание'}",
|
||
]
|
||
payload = {
|
||
"ready": ready,
|
||
"message": "Готово, открываем..." if ready else "Запуск сессии...",
|
||
"steps": steps,
|
||
}
|
||
if pooled_web or pooled_rdp:
|
||
payload["redirect_url"] = f"/s/{session_id}/view"
|
||
if web_pool_idx is not None:
|
||
payload["redirect_url"] = f"/s/{session_id}/view"
|
||
if universal_pool_idx is not None:
|
||
payload["redirect_url"] = f"/s/{session_id}/view"
|
||
if rdp_slot_idx is not None:
|
||
payload["redirect_url"] = f"/s/{session_id}/view"
|
||
return payload
|
||
|
||
|
||
@app.post("/api/admin/services")
|
||
def create_service(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
validate_csrf(request)
|
||
service_type = ServiceType(payload["type"])
|
||
if service_type == ServiceType.VNC:
|
||
raise HTTPException(status_code=400, detail="VNC services are no longer supported")
|
||
target = payload["target"]
|
||
if service_type == ServiceType.WEB:
|
||
target = normalize_web_target(target)
|
||
elif service_type == ServiceType.RDP:
|
||
parse_rdp_target(target)
|
||
service = Service(
|
||
name=payload["name"],
|
||
slug=payload["slug"],
|
||
type=service_type,
|
||
target=target,
|
||
comment=payload.get("comment", ""),
|
||
svc_login=payload.get("svc_login", ""),
|
||
svc_password=payload.get("svc_password", ""),
|
||
svc_cred_hint=payload.get("svc_cred_hint", ""),
|
||
active=payload.get("active", True),
|
||
warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))),
|
||
is_pilot=bool(payload.get("is_pilot", False)),
|
||
)
|
||
db.add(service)
|
||
db.flush()
|
||
set_service_categories(db, service.id, payload.get("category_ids", []))
|
||
db.commit()
|
||
if service.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||
ensure_warm_pool(service)
|
||
elif service_uses_universal_pool(service):
|
||
ensure_universal_pool()
|
||
return {"id": service.id}
|
||
|
||
|
||
@app.get("/api/admin/services/{service_id}/containers/status")
|
||
def service_containers_status(service_id: int, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
service = db.get(Service, service_id)
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="Service not found")
|
||
out = get_pool_detailed_status(service)
|
||
out["active_sessions"] = get_active_sessions_count(db, service.id)
|
||
return out
|
||
|
||
|
||
@app.post("/api/admin/services/{service_id}/icon")
|
||
async def upload_service_icon(
|
||
service_id: int,
|
||
request: Request,
|
||
file: UploadFile = File(...),
|
||
_: User = Depends(require_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
validate_csrf(request)
|
||
service = db.get(Service, service_id)
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="Service not found")
|
||
new_path = await store_service_icon(service, file)
|
||
old_path = service.icon_path
|
||
service.icon_path = new_path
|
||
db.commit()
|
||
if old_path and old_path != new_path:
|
||
remove_icon_file(old_path)
|
||
return {"ok": True, "icon_path": new_path}
|
||
|
||
|
||
@app.delete("/api/admin/services/{service_id}/icon")
|
||
def delete_service_icon(service_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
validate_csrf(request)
|
||
service = db.get(Service, service_id)
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="Service not found")
|
||
old_path = service.icon_path
|
||
service.icon_path = ""
|
||
db.commit()
|
||
remove_icon_file(old_path)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.put("/api/admin/services/{service_id}")
|
||
def edit_service(service_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
validate_csrf(request)
|
||
service = db.get(Service, service_id)
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="Service not found")
|
||
for key in ["name", "slug", "target", "active", "comment", "svc_login", "svc_password", "svc_cred_hint", "is_pilot"]:
|
||
if key in payload:
|
||
setattr(service, key, payload[key])
|
||
if "type" in payload:
|
||
service.type = ServiceType(payload["type"])
|
||
if service.type == ServiceType.VNC:
|
||
raise HTTPException(status_code=400, detail="VNC services are no longer supported")
|
||
if service.type == ServiceType.WEB:
|
||
service.target = normalize_web_target(service.target)
|
||
elif service.type == ServiceType.RDP:
|
||
parse_rdp_target(service.target)
|
||
if "warm_pool_size" in payload:
|
||
service.warm_pool_size = max(0, int(payload["warm_pool_size"]))
|
||
if "category_ids" in payload:
|
||
set_service_categories(db, service.id, payload.get("category_ids", []))
|
||
db.commit()
|
||
if service.type == ServiceType.WEB:
|
||
if WEB_POOL_SIZE <= 0:
|
||
ensure_warm_pool(service)
|
||
open_warm_web_url(service, service.target)
|
||
elif service_uses_universal_pool(service):
|
||
ensure_universal_pool()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.delete("/api/admin/services/{service_id}")
|
||
def delete_service(service_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
validate_csrf(request)
|
||
service = db.get(Service, service_id)
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="Service not found")
|
||
if service.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||
ensure_warm_pool(service, 0)
|
||
remove_icon_file(service.icon_path)
|
||
db.delete(service)
|
||
db.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/admin/services/{service_id}/prewarm")
|
||
def prewarm_now(service_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
validate_csrf(request)
|
||
service = db.get(Service, service_id)
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="Service not found")
|
||
if service.type == ServiceType.WEB:
|
||
ensure_web_pool()
|
||
return {"ok": True, "pool": get_web_pool_status()}
|
||
if service_uses_universal_pool(service):
|
||
ensure_universal_pool()
|
||
return {"ok": True, "pool": get_universal_pool_status()}
|
||
if service.type == ServiceType.RDP:
|
||
return {"ok": True, "pool": get_pool_status_for_service(service), "message": "RDP запускается on-demand"}
|
||
ensure_warm_pool(service)
|
||
return {"ok": True, "pool": get_pool_status_for_service(service)}
|
||
|
||
|
||
@app.post("/api/admin/services/{service_id}/rdp-slots")
|
||
def create_rdp_slot(service_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
validate_csrf(request)
|
||
service = db.get(Service, service_id)
|
||
if not service or service.type != ServiceType.RDP:
|
||
raise HTTPException(status_code=404, detail="RDP service not found")
|
||
rdp_username = (payload.get("rdp_username") or "").strip()
|
||
rdp_password = (payload.get("rdp_password") or "").strip()
|
||
if not rdp_username:
|
||
raise HTTPException(status_code=400, detail="rdp_username is required")
|
||
slot = RdpSlot(service_id=service_id, rdp_username=rdp_username, rdp_password=rdp_password)
|
||
db.add(slot)
|
||
db.flush()
|
||
try:
|
||
container_name = start_rdp_slot_container(slot, service)
|
||
slot.container_name = container_name
|
||
except Exception as exc:
|
||
logger.exception("rdp_slot_container_start_failed service_id=%s", service_id)
|
||
raise HTTPException(status_code=502, detail=f"Контейнер не запустился: {exc}")
|
||
db.commit()
|
||
audit(db, "RDP_SLOT_CREATE", f"service={service.slug} slot={slot.id} user={rdp_username}", user_id=None)
|
||
return {"ok": True, "slot_id": slot.id, "container_name": slot.container_name}
|
||
|
||
|
||
@app.delete("/api/admin/rdp-slots/{slot_id}")
|
||
def delete_rdp_slot(slot_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
validate_csrf(request)
|
||
slot = db.get(RdpSlot, slot_id)
|
||
if not slot:
|
||
raise HTTPException(status_code=404, detail="Slot not found")
|
||
container_name = slot.container_name
|
||
db.delete(slot)
|
||
db.commit()
|
||
if container_name:
|
||
threading.Thread(target=stop_rdp_slot_container, args=(container_name,), daemon=True).start()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.put("/api/admin/rdp-slots/{slot_id}/assign")
|
||
def assign_rdp_slot(slot_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
"""Bind (or unbind, with user_id null) a pilot's RDP slot to one
|
||
specific person. This is the single action that gives someone a pilot:
|
||
it both points the slot at them AND grants/revokes their
|
||
UserServiceAccess row for the pilot service, so assigning a slot here
|
||
is enough to make the pilot usable for them - no separate ACL step on
|
||
the Users tab. Optional "expires_at" (ISO string or null) sets a
|
||
per-grant expiry shorter than the account's own.
|
||
|
||
Only meaningful for slots that belong to a pilot service - a slot on a
|
||
regular pooled RDP service doesn't need this, since any free slot in
|
||
the pool already works for anyone with access."""
|
||
validate_csrf(request)
|
||
slot = db.get(RdpSlot, slot_id)
|
||
if not slot:
|
||
raise HTTPException(status_code=404, detail="Slot not found")
|
||
service = db.get(Service, slot.service_id)
|
||
if not service or not service.is_pilot:
|
||
raise HTTPException(status_code=400, detail="Слот принадлежит не пилотному сервису")
|
||
|
||
prev_user_id = slot.assigned_user_id
|
||
raw_user_id = payload.get("user_id")
|
||
|
||
if raw_user_id in (None, ""):
|
||
slot.assigned_user_id = None
|
||
if prev_user_id:
|
||
prev_access = db.scalar(
|
||
select(UserServiceAccess).where(
|
||
UserServiceAccess.user_id == prev_user_id,
|
||
UserServiceAccess.service_id == service.id,
|
||
)
|
||
)
|
||
if prev_access:
|
||
db.delete(prev_access)
|
||
db.commit()
|
||
audit(db, "RDP_SLOT_UNASSIGN", f"service={service.slug} slot={slot.id}", user_id=None)
|
||
return {"ok": True, "assigned_user_id": None}
|
||
|
||
target_user = db.get(User, int(raw_user_id))
|
||
if not target_user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
other = db.scalar(
|
||
select(RdpSlot).where(
|
||
RdpSlot.service_id == service.id,
|
||
RdpSlot.assigned_user_id == target_user.id,
|
||
RdpSlot.id != slot.id,
|
||
)
|
||
)
|
||
if other:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"У пользователя уже есть слот №{other.id} на этом пилоте",
|
||
)
|
||
|
||
raw_expires = payload.get("expires_at")
|
||
expires_at = dt.datetime.fromisoformat(raw_expires) if raw_expires else None
|
||
|
||
if prev_user_id and prev_user_id != target_user.id:
|
||
prev_access = db.scalar(
|
||
select(UserServiceAccess).where(
|
||
UserServiceAccess.user_id == prev_user_id,
|
||
UserServiceAccess.service_id == service.id,
|
||
)
|
||
)
|
||
if prev_access:
|
||
db.delete(prev_access)
|
||
|
||
access = db.scalar(
|
||
select(UserServiceAccess).where(
|
||
UserServiceAccess.user_id == target_user.id,
|
||
UserServiceAccess.service_id == service.id,
|
||
)
|
||
)
|
||
is_new_grant = access is None
|
||
if access:
|
||
access.expires_at = expires_at
|
||
else:
|
||
db.add(UserServiceAccess(user_id=target_user.id, service_id=service.id, expires_at=expires_at))
|
||
|
||
slot.assigned_user_id = target_user.id
|
||
db.commit()
|
||
audit(db, "RDP_SLOT_ASSIGN", f"service={service.slug} slot={slot.id} user={target_user.username}", user_id=None)
|
||
email_status = None
|
||
if is_new_grant:
|
||
# Only on a genuinely new grant - not every time the admin tweaks
|
||
# the expiry date for someone who already has this pilot, which
|
||
# also calls this endpoint.
|
||
email_status = _send_pilot_granted_email(target_user, service, expires_at)
|
||
return {"ok": True, "assigned_user_id": target_user.id, "email_status": email_status}
|
||
|
||
|
||
@app.post("/api/admin/categories")
|
||
def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
validate_csrf(request)
|
||
name = (payload.get("name") or "").strip()
|
||
slug = (payload.get("slug") or "").strip().lower().replace(" ", "-")
|
||
if not name:
|
||
raise HTTPException(status_code=400, detail="Category name is required")
|
||
if not slug:
|
||
raise HTTPException(status_code=400, detail="Category slug is required")
|
||
exists = db.scalar(select(Category).where((Category.name == name) | (Category.slug == slug)))
|
||
if exists:
|
||
raise HTTPException(status_code=409, detail="Category already exists")
|
||
category = Category(name=name, slug=slug)
|
||
db.add(category)
|
||
db.commit()
|
||
return {"id": category.id}
|
||
|
||
|
||
@app.delete("/api/admin/categories/{category_id}")
|
||
def delete_category(category_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
validate_csrf(request)
|
||
category = db.get(Category, category_id)
|
||
if not category:
|
||
raise HTTPException(status_code=404, detail="Category not found")
|
||
db.delete(category)
|
||
db.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.put("/api/admin/web-pool-size")
|
||
def update_web_pool_size(payload: dict, request: Request, _: User = Depends(require_admin)):
|
||
validate_csrf(request)
|
||
global WEB_POOL_SIZE
|
||
value = max(0, int(payload.get("size", WEB_POOL_SIZE)))
|
||
WEB_POOL_SIZE = value
|
||
ensure_web_pool()
|
||
return {"ok": True, "size": WEB_POOL_SIZE, "pool": get_web_pool_status()}
|
||
|
||
|
||
@app.post("/api/admin/users")
|
||
def create_user(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
"""The password is never typed by the admin - it's generated here and
|
||
mailed to the user (username is their email, same convention as the
|
||
self-service approval flow). If the email fails to send, the plaintext
|
||
comes back in the response as a one-time fallback so the admin isn't
|
||
locked out of handing it over; the frontend only surfaces it then."""
|
||
validate_csrf(request)
|
||
expires_at = dt.datetime.fromisoformat(payload["expires_at"])
|
||
password = _generate_password()
|
||
user = User(
|
||
username=payload["username"],
|
||
password_hash=hash_password(password),
|
||
expires_at=expires_at,
|
||
active=payload.get("active", True),
|
||
is_admin=payload.get("is_admin", False),
|
||
first_name=payload.get("first_name", ""),
|
||
last_name=payload.get("last_name", ""),
|
||
)
|
||
db.add(user)
|
||
db.commit()
|
||
email_status = _send_welcome_email(user, password)
|
||
result = {"id": user.id, "email_status": email_status}
|
||
if not email_status.startswith("Email отправлен"):
|
||
result["password"] = password
|
||
return result
|
||
|
||
|
||
@app.put("/api/admin/users/{user_id}")
|
||
def edit_user(user_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
validate_csrf(request)
|
||
user = db.get(User, user_id)
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
for key in ["username", "active", "is_admin", "first_name", "last_name"]:
|
||
if key in payload:
|
||
setattr(user, key, payload[key])
|
||
if "expires_at" in payload:
|
||
user.expires_at = dt.datetime.fromisoformat(payload["expires_at"])
|
||
db.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/admin/users/{user_id}/reset-password")
|
||
def reset_user_password(user_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
"""Generates a new password and emails it - the only way a user's
|
||
password changes now, mirroring create_user(). Same fallback: if the
|
||
email bounces, the plaintext comes back so the admin can pass it on."""
|
||
validate_csrf(request)
|
||
user = db.get(User, user_id)
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
password = _generate_password()
|
||
user.password_hash = hash_password(password)
|
||
db.commit()
|
||
email_status = _send_password_reset_email(user, password)
|
||
result = {"ok": True, "email_status": email_status}
|
||
if not email_status.startswith("Email отправлен"):
|
||
result["password"] = password
|
||
return result
|
||
|
||
|
||
@app.delete("/api/admin/users/{user_id}")
|
||
def delete_user(user_id: int, request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
validate_csrf(request)
|
||
user = db.get(User, user_id)
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
if user.id == admin.id:
|
||
raise HTTPException(status_code=400, detail="Cannot delete current admin")
|
||
db.delete(user)
|
||
db.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.put("/api/admin/users/{user_id}/acl")
|
||
def set_acl(user_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||
"""Replace a user's non-pilot product grants with the posted set.
|
||
Pilots are deliberately out of scope here - they're granted/revoked
|
||
only via the per-slot assign endpoint on the Pilots tab, which is also
|
||
what points a specific container at the person. The Users tab ACL grid
|
||
doesn't render pilot checkboxes at all, so service_ids posted from
|
||
there never includes them; if this endpoint treated "pilot id missing
|
||
from service_ids" as "revoke it", saving any other product's ACL would
|
||
silently strip every pilot grant the user has."""
|
||
validate_csrf(request)
|
||
user = db.get(User, user_id)
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="User not found")
|
||
service_ids = set(payload.get("service_ids", []))
|
||
pilot_ids = set(db.scalars(select(Service.id).where(Service.is_pilot == True)).all())
|
||
|
||
existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all()
|
||
existing_map = {x.service_id: x for x in existing}
|
||
|
||
for sid in service_ids - pilot_ids:
|
||
if sid not in existing_map:
|
||
db.add(UserServiceAccess(user_id=user_id, service_id=sid))
|
||
removed_ids = [
|
||
sid for sid, row in existing_map.items()
|
||
if sid not in service_ids and sid not in pilot_ids
|
||
]
|
||
for sid in removed_ids:
|
||
db.delete(existing_map[sid])
|
||
|
||
db.commit()
|
||
return {"ok": True}
|