diff --git a/app/config.py b/app/config.py
index 63e7be4..0b48028 100644
--- a/app/config.py
+++ b/app/config.py
@@ -45,3 +45,8 @@ SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", "stand@4mont.ru")
SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "\u0418\u043d\u0444\u0440\u0430\u0441\u0442\u0443\u043a\u0442\u0443\u0440\u043d\u044b\u0439 \u043f\u043e\u043b\u0438\u0433\u043e\u043d MONT")
PORTAL_URL = os.getenv("PORTAL_URL", "https://stend.4mont.ru")
+
+# Second approval channel: email notification with click-to-confirm links,
+# sent alongside the Telegram message when a new access request comes in.
+ADMIN_NOTIFY_EMAIL = os.getenv("ADMIN_NOTIFY_EMAIL", "RGalyaviev@mont.ru")
+SIGNING_KEY = os.getenv("SIGNING_KEY", "")
diff --git a/app/main.py b/app/main.py
index 0204079..bf82947 100644
--- a/app/main.py
+++ b/app/main.py
@@ -8,7 +8,7 @@ import time
import contextvars
from typing import Optional
-from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile, status
+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
@@ -21,13 +21,14 @@ import urllib.request as _urllib_request
import urllib.parse as _urllib_parse
import json as _json
from config import (
- COOKIE_NAME, CSRF_COOKIE, GO_POOL_LOCK_TIMEOUT_SECONDS,
+ 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 (
@@ -35,9 +36,9 @@ from models import (
PendingAccessRequest, SessionModel, SessionStatus, User, UserServiceAccess,
)
from utils import (
- audit, ensure_icons_dir, format_service_comment, 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,
+ 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,
@@ -45,6 +46,7 @@ from auth import (
check_login_rate_limit,
record_login_failure,
record_login_success,
+ serializer,
)
from runtime import (
acquire_universal_slot, acquire_web_pool_slot, allocator_lock,
@@ -113,7 +115,19 @@ def _generate_password(length: int = 10) -> str:
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-адрес - уведомление не отправлено")
msg = _MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = _formataddr((str(_Header(SMTP_FROM_NAME, "utf-8")), SMTP_FROM_EMAIL))
@@ -147,6 +161,290 @@ def _make_approval_keyboard(req_id: str) -> dict:
}
+# --- 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"
{escape(p)}
" for p in products)
+ products_html = (
+ '
Предоставлен доступ к продуктам:
'
+ f'
{items}
'
+ )
+
+ 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'
Пароль
'
+ f'
{password}
'
+ ) if not is_renewal else ""
+ access_text = f"Вам {email_action} доступ к полигону на {days} {day_word}."
+ html_email = f"""
+
+
+
Второй канал согласования — дублирует запрос, отправленный в Telegram
+
+
+
+
+
Имя
{escape(pending.name)}
+
Компания
{escape(pending.company)}
+
Email
{escape(pending.email)}
+
Телефон
{escape(pending.phone)}
+
Менеджер
{escape(pending.manager) if pending.manager else "—"}
+
+ {products_line}
+
Выдать доступ на:
+
{buttons_html}
+
Ссылка открывает страницу подтверждения — доступ выдаётся только по нажатию кнопки на ней, а не по самому переходу (безопасно для антифишинг-сканеров почты, которые сами открывают ссылки).
+
+
+ Заявка #{pending.id}
+
+
+"""
+
+ 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"""
+{title}
+
+
+
+
+
{body}
+
+"""
+ return _HR(html)
+
+
+
_tg_poll_offset: int = 0
_tg_poll_lock_file = None
@@ -175,6 +473,49 @@ async def _telegram_poll_loop():
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
@@ -250,13 +591,20 @@ async def _process_callback_query(cq: dict):
target_user = new_user
if products:
- from sqlalchemy import func as _func2
- matched = db.scalars(
- select(Service).where(
- _func2.lower(Service.name).in_([p.lower() for p in products]),
- Service.active == True,
- )
- ).all()
+ _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()
@@ -269,7 +617,7 @@ async def _process_callback_query(cq: dict):
products_html = ""
if products:
- items = "".join(f"
К сожалению, на данный момент мы не можем предоставить доступ к полигону.
- Для уточнения деталей свяжитесь с {manager_contact}.
+ Для уточнения деталей свяжитесь с {escape(manager_contact)}.
Если не знаете кто ваш менеджер — напишите на mont@mont.ru.
@@ -384,6 +732,69 @@ async def _process_callback_query(cq: dict):
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("Ссылка недействительна", "
Ссылка недействительна или устарела.
")
+
+ pending = db.get(PendingAccessRequest, req_id)
+ if not pending:
+ return _decision_page("Запрос не найден", "
")
+
+ 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"
Логин: {escape(result['username'])}
"
+ if not result["is_renewal"]:
+ cred += f"
Пароль: {escape(result['password'])}
"
+ title_word = "Продлено" if result["is_renewal"] else "Одобрено"
+ body = f"""
+
{title_word} на {days} {day_word}
+ {cred}
+
{escape(result['email_status'])}
+ """
+ else:
+ body = f"""
+
Отклонено
+
{escape(result['email_status'])}
+ """
+ return _decision_page("Готово", body)
+
app.mount("/static", StaticFiles(directory="static"), name="static")
@@ -470,11 +881,56 @@ _MOBILE_PAGE = (
)
+# 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("/static/"):
+ 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)
@@ -491,10 +947,18 @@ async def startup_event():
_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 services shown as a logo wall on the public login page."""
+ return db.scalars(
+ select(Service).where(Service.active == 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()
@@ -521,26 +985,30 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
"csrf_token": csrf,
"login_error": "",
"session_notice": session_notice,
+ "public_services": _login_wall_services(db),
},
)
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="lax", path="/")
return response
- services = db.scalars(
+ all_services = db.scalars(
select(Service)
- .join(UserServiceAccess, UserServiceAccess.service_id == Service.id)
- .where(
- UserServiceAccess.user_id == user.id,
- Service.active == True,
- Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
- )
+ .where(Service.active == True, Service.type.in_([ServiceType.WEB, ServiceType.RDP]))
.order_by(Service.name)
).all()
+ granted_ids = set(
+ db.scalars(select(UserServiceAccess.service_id).where(UserServiceAccess.user_id == user.id)).all()
+ )
+ services = [svc for svc in all_services if svc.id in granted_ids]
+ locked_services = [svc for svc in all_services if svc.id not in granted_ids]
- service_categories = {svc.id: [] for svc in services}
+ # 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 services:
- service_ids = [svc.id for svc in services]
+ 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)
@@ -560,13 +1028,28 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
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, []))
]
- service_comment_html = {svc.id: format_service_comment(svc.comment) for svc in services}
+ 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",
@@ -574,12 +1057,37 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
"request": request,
"user": user,
"services": services,
+ "locked_services": locked_services,
"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,
},
)
@@ -855,7 +1363,7 @@ async def telegram_webhook(request: Request, db: Session = Depends(get_db)):
# send approval email
products_html = ""
if products:
- items = "".join(f"
К сожалению, на данный момент мы не можем предоставить доступ к полигону.
- Для уточнения деталей, пожалуйста, свяжитесь с {manager_contact}.
+ Для уточнения деталей, пожалуйста, свяжитесь с {escape(manager_contact)}.
Если вы не знаете, кто ваш менеджер, напишите нам на mont@mont.ru — мы поможем.
@@ -967,6 +1475,67 @@ 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 /
) 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
@@ -974,9 +1543,26 @@ def robots_txt():
@app.get("/sitemap.xml", include_in_schema=False)
-def sitemap_xml():
- from fastapi.responses import FileResponse
- return FileResponse("static/sitemap.xml", media_type="application/xml")
+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" \n {loc}\n weekly\n {priority}\n "
+ for loc, priority in entries
+ ]
+ xml = (
+ '\n'
+ '\n'
+ + "\n".join(url_blocks) + "\n\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)):
@@ -1005,19 +1591,54 @@ def public_services_by_category(db: Session = Depends(get_db)):
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()
- company = str(data.get("company", "")).strip()
- email = str(data.get("email", "")).strip()
- phone = str(data.get("phone", "")).strip()
- manager = str(data.get("manager", "")).strip()
- products = data.get("products", [])
+ 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:
@@ -1074,6 +1695,7 @@ async def request_access(request: Request, db: Session = Depends(get_db)):
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()
@@ -1085,72 +1707,163 @@ async def request_access(request: Request, db: Session = Depends(get_db)):
"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}
-@app.post("/api/contact")
-async def contact_ruslan(request: Request):
- import re as _re
+# /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")
- name = str(data.get("name", "")).strip()
- email = str(data.get("email", "")).strip()
- phone = str(data.get("phone", "")).strip()
- text = str(data.get("text", "")).strip()
+ 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 name or not email or not phone or not text:
- 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="Некорректный номер телефона")
+ 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
+ matched = db.scalars(
+ select(Service).where(
+ _func4.lower(Service.name).in_([p.lower() for p in requested]),
+ Service.active == True,
+ )
+ ).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💬 Комментарий: {_e(note)}" if note else ""
divider = "━━━━━━━━━━━━━━━━━━━━━━"
- msg = (
- f"🔔 *Сообщение через форму полигона*\n"
+ text = (
+ f"🔔 Запрос дополнительного доступа\n"
f"{divider}\n\n"
- f"👤 *Имя:* {name}\n"
- f"📧 *Email:* {email}\n"
- f"📱 *Телефон:* {phone}\n\n"
- f"💬 *Сообщение:*\n{text}"
+ f"👤 Пользователь: {_e(display_name)} ({_e(user.username)})\n"
+ f"🖥 Запрошенные продукты:\n{items}"
+ f"{note_text}"
)
- ip = _get_real_ip(request)
+ ip = _get_real_ip(request)
geo = _get_geo(ip)
geo_text = ""
if geo:
- geo_text += f"\n📍 *Местоположение:* {geo}"
- geo_text += f"\n🖥 *IP:* {ip}"
- msg += geo_text
+ geo_text += "\n📍 Местоположение: " + _e(geo)
+ geo_text += "\n🖥 IP: " + _e(ip)
+ text += geo_text
- if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
+ 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}
-
- try:
- payload = _json.dumps({
- "chat_id": TELEGRAM_CHAT_ID,
- "text": msg,
- "parse_mode": "Markdown",
- }).encode()
- url = f"{TELEGRAM_API_URL}{TELEGRAM_BOT_TOKEN}/sendMessage"
- req = _urllib_request.Request(url, data=payload, headers={"Content-Type": "application/json"})
- with _urllib_request.urlopen(req, timeout=10) as resp:
- resp.read()
- except Exception as e:
- log_event("telegram_send_error", error=str(e))
- raise HTTPException(status_code=502, detail="Ошибка отправки")
return {"ok": True}
+
+
@app.post("/login")
def login(
request: Request,
@@ -1178,6 +1891,7 @@ def login(
"csrf_token": csrf,
"login_error": "Неверный логин или пароль",
"session_notice": "",
+ "public_services": _login_wall_services(db),
},
status_code=401,
)
@@ -1191,6 +1905,8 @@ def login(
"request": request,
"csrf_token": csrf,
"login_error": "Доступ к сервису приостоновлен, обратитесь к вашему менеджеру",
+ "session_notice": "",
+ "public_services": _login_wall_services(db),
},
status_code=403,
)
diff --git a/app/models.py b/app/models.py
index 96ebb9a..0e8615d 100644
--- a/app/models.py
+++ b/app/models.py
@@ -50,6 +50,7 @@ class Service(Base):
svc_password: Mapped[str] = mapped_column(String(256), default="")
svc_cred_hint: Mapped[str] = mapped_column(Text, default="")
icon_path: Mapped[str] = mapped_column(Text, default="")
+ seo_description: Mapped[str] = mapped_column(Text, default="")
active: Mapped[bool] = mapped_column(Boolean, default=True)
warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
@@ -130,3 +131,9 @@ class PendingAccessRequest(Base):
portal_url: Mapped[str] = mapped_column(String(256), default="")
status: Mapped[str] = mapped_column(String(16), default="pending")
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
+ # Telegram delivery tracking: the notification is sent once when the
+ # request is created; if that attempt fails (tel.4mont.ru down/timeout),
+ # a background retry loop resends telegram_message every 5 minutes until
+ # it succeeds, without recomputing geo/IP each time.
+ telegram_notified: Mapped[bool] = mapped_column(Boolean, default=False)
+ telegram_message: Mapped[str] = mapped_column(Text, default="")
diff --git a/app/runtime.py b/app/runtime.py
index 7e15f35..0b96168 100644
--- a/app/runtime.py
+++ b/app/runtime.py
@@ -772,7 +772,10 @@ def ensure_schema_compatibility() -> None:
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_password VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_cred_hint TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''"))
+ conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS seo_description TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS portal_url VARCHAR(256) NOT NULL DEFAULT ''"))
+ conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS telegram_notified BOOLEAN NOT NULL DEFAULT false"))
+ conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS telegram_message TEXT NOT NULL DEFAULT ''"))
conn.execute(
text(
"""
diff --git a/app/static/logo-color.png b/app/static/logo-color.png
new file mode 100644
index 0000000..3412dc3
Binary files /dev/null and b/app/static/logo-color.png differ
diff --git a/app/templates/admin.html b/app/templates/admin.html
index 42fa393..f086577 100644
--- a/app/templates/admin.html
+++ b/app/templates/admin.html
@@ -23,8 +23,179 @@
+
-
+
Заказчику редко нужен один продукт в вакууме — обычно решение должно встроиться в существующую или новую инфраструктуру. Мы собираем на полигоне связку из нескольких продуктов, имитирующую реальный сценарий внедрения, чтобы показать не «продукт», а готовое решение задачи. Раздел готовится — ниже сценарии, которые уже прорабатываются.
+
+
+
+
В подготовке
+
+
+ Готовится
+
Миграция домена
+
ALDpro в связке с рабочими станциями на Astra Linux, РЕД ОС и Альт Рабочая станция — вход, групповые политики и совместная работа каталога с разными ОС на местах.
+
+
+ Готовится
+
VDI-инфраструктура
+
Termidesk поверх Ред Виртуализация — типовой сценарий виртуальных рабочих столов, от сервера виртуализации до подключения пользователя.
+
+
+ Готовится
+
Резервное копирование виртуальной инфраструктуры
+
RuBackup или Vinchin в связке с Альт Виртуализация (PVE) — бэкап и восстановление виртуальных машин на живом кластере.
+
+
+ Готовится
+
Импортозамещение рабочего места целиком
+
ОС (Astra Linux / РЕД ОС / Альт Рабочая станция) + офисный пакет (АльтерОфис / P7-КС2024) + почта (CommuniGate Pro / RuPost) — полный стек рабочего места сотрудника.
+
+
+ Готовится
+
Контейнеризированное приложение с базой данных
+
Deckhouse Platform (Kubernetes) + Postgres Pro или Tantor — развёртывание приложения с реальной СУБД под капотом.
+
+
+ Готовится
+
Наблюдаемость виртуальной инфраструктуры
+
Astra Мониторинг поверх Ред Виртуализация или Альт Виртуализация (PVE) — контроль состояния кластера и виртуальных машин.