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'' + ) + + 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""" + + +
    + + + + +
    + MONT
    +

    {email_subject}

    +

    {email_subhead}

    +
    +
    +

    Здравствуйте, {escape(pending.name)}!
    + {access_text}

    + + + + + + {cred_row} + + +
    Адрес портала{portal_url}
    Логин{username}
    Доступ до{expires.strftime("%d.%m.%Y")}
    + {products_html} + +
    + Если у вас возникли вопросы, свяжитесь с вашим менеджером MONT или напишите на mont@mont.ru +
    +""" + + 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""" + + +
    + + + + +
    + MONT
    +

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

    +
    +
    +

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

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

    +

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

    +
    + С уважением, команда MONT +
    +""" + + pending.status = "rejected" + db.commit() + + try: + _send_email(pending.email, "Запрос на доступ к полигону MONT", html_email) + email_status = "Email отправлен" + except Exception as ex: + log_event("email_send_error", error=str(ex), 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'{_ACTION_LABELS[action]}' + ) + reject_token = _make_decision_token(pending.id, "r") + reject_url = f"{decide_base}?action=r&token={_urllib_parse.quote(reject_token)}" + buttons_html += ( + f'Отказать' + ) + + products_line = "" + try: + import json as _jg2 + items = _jg2.loads(pending.products_json or "[]") + if items: + products_line = ('

    Продукты: ' + + ", ".join(escape(str(p)) for p in items) + '

    ') + except Exception: + pass + + html = f""" + + +
    + + + + +
    +

    Новый запрос доступа к полигону MONT

    +

    Второй канал согласования — дублирует запрос, отправленный в 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"
  • {p}
  • " for p in products) + items = "".join(f"
  • {escape(p)}
  • " for p in products) products_html = ( '

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

    ' f'' @@ -298,7 +646,7 @@ async def _process_callback_query(cq: dict):
    -

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

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

    @@ -354,10 +702,10 @@ async def _process_callback_query(cq: dict):
    Адрес портала
    -

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

    +

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

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

    - Для уточнения деталей свяжитесь с {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("Запрос не найден", "

    Запрос не найден (возможно, уже обработан).

    ") + if pending.status != "pending": + return _decision_page("Уже обработано", f"

    Запрос уже обработан: {pending.status}.

    ") + + decision_label = "Отказать" if action == "r" else f"Одобрить на {_ACTION_LABELS[action]}" + body = f""" +

    Подтвердите решение

    +

    {escape(pending.name)} ({escape(pending.company)})
    {escape(pending.email)} · {escape(pending.phone)}

    +

    Решение: {decision_label}

    +
    + + + +
    + """ + 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("Ссылка недействительна", "

    Ссылка недействительна или устарела.

    ") + + pending = db.get(PendingAccessRequest, req_id) + if not pending: + return _decision_page("Запрос не найден", "

    Запрос не найден (возможно, уже обработан).

    ") + if pending.status != "pending": + return _decision_page("Уже обработано", f"

    Запрос уже обработан: {pending.status}.

    ") + + 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"
  • {p}
  • " for p in products) + items = "".join(f"
  • {escape(p)}
  • " for p in products) products_html = f"

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

      {items}
    " html_email = f""" @@ -870,7 +1378,7 @@ async def telegram_webhook(request: Request, db: Session = Depends(get_db)):
    -

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

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

    @@ -930,10 +1438,10 @@ async def telegram_webhook(request: Request, db: Session = Depends(get_db)):
    Адрес портала
    -

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

    +

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

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

    - Для уточнения деталей, пожалуйста, свяжитесь с {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 /<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 @@ -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" <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)): @@ -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💬 <b>Комментарий:</b> {_e(note)}" if note else "" divider = "━━━━━━━━━━━━━━━━━━━━━━" - msg = ( - f"🔔 *Сообщение через форму полигона*\n" + text = ( + f"🔔 <b>Запрос дополнительного доступа</b>\n" f"{divider}\n\n" - f"👤 *Имя:* {name}\n" - f"📧 *Email:* {email}\n" - f"📱 *Телефон:* {phone}\n\n" - f"💬 *Сообщение:*\n{text}" + f"👤 <b>Пользователь:</b> {_e(display_name)} ({_e(user.username)})\n" + f"🖥 <b>Запрошенные продукты:</b>\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📍 <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: + 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 @@ </script> <noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript> <!-- /Yandex.Metrika counter --> +<style> + :root{ + --av-bg:#eef1f5; --av-panel:#fff; --av-raised:#fff; + --av-line:#d7dee6; --av-line-soft:#e3e8ee; + --av-fg:#16202c; --av-fg-dim:#5c6b7c; --av-fg-faint:#93a1b0; + --av-accent:#c96c14; --av-accent-dim:rgba(201,108,20,.10); --av-accent-ink:#2a1400; + --av-good:#1f9d63; --av-warn:#b5680a; --av-bad:#d3453f; + } + + body.admin-page-v2{ + background:var(--av-bg) !important; color:var(--av-fg); + font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif; + } + body.admin-page-v2 *{ box-sizing:border-box; min-width:0; } + + /* header */ + body.admin-page-v2 .header{ + background:var(--av-panel) !important; border-bottom:1px solid var(--av-line); + backdrop-filter:none; + } + body.admin-page-v2 .header-logo{ height:28px; } + body.admin-page-v2 .header > div > div{ + font:600 13px/1 "Ubuntu Mono",monospace; color:var(--av-fg-dim); letter-spacing:.02em; + } + body.admin-page-v2 .btn-link.secondary{ + background:transparent !important; border:1px solid var(--av-line); color:var(--av-fg-dim) !important; + font:600 13px/1 "Ubuntu",sans-serif; padding:9px 16px; border-radius:7px; + } + body.admin-page-v2 .btn-link.secondary:hover{ border-color:var(--av-accent); color:var(--av-fg) !important; } + + /* layout / panels */ + body.admin-page-v2 .admin-layout{ max-width:1500px; } + body.admin-page-v2 .panel{ + background:var(--av-panel) !important; border:1px solid var(--av-line); box-shadow:none; + border-radius:12px; + } + body.admin-page-v2 .admin-intro{ + background:var(--av-accent-dim) !important; border:1px solid var(--av-line); + border-left:3px solid var(--av-accent); color:var(--av-fg-dim) !important; + font:500 13px/1.55 "Ubuntu",sans-serif; letter-spacing:normal; + } + body.admin-page-v2 .admin-intro b{ color:var(--av-fg); } + + /* tabs */ + body.admin-page-v2 .tab-row{ gap:6px; padding-bottom:2px; } + body.admin-page-v2 .tab-btn{ + background:transparent !important; color:var(--av-fg-dim) !important; + border:1px solid var(--av-line); border-radius:8px; + font:600 13px/1 "Ubuntu",sans-serif; padding:9px 16px; + } + body.admin-page-v2 .tab-btn:hover{ border-color:var(--av-fg-faint); color:var(--av-fg) !important; } + body.admin-page-v2 .tab-btn.active-tab{ + background:var(--av-accent-dim) !important; border-color:var(--av-accent); + color:var(--av-fg) !important; + } + + /* split / lists */ + body.admin-page-v2 .split{ grid-template-columns:minmax(280px,340px) minmax(0,1fr); } + @media (max-width:900px){ body.admin-page-v2 .split{ grid-template-columns:minmax(0,1fr); } } + body.admin-page-v2 .list-title{ + font:700 11px/1 "Ubuntu Mono",monospace; letter-spacing:.1em; text-transform:uppercase; + color:var(--av-fg-faint); + } + body.admin-page-v2 .list-box{ + background:var(--av-raised) !important; border:1px solid var(--av-line); border-radius:10px; + } + body.admin-page-v2 .list-search{ + background:var(--av-panel) !important; border:1px solid var(--av-line) !important; + color:var(--av-fg); border-radius:8px; + } + body.admin-page-v2 .list-search::placeholder{ color:var(--av-fg-faint); } + body.admin-page-v2 .list-item{ + background:var(--av-panel) !important; border:1px solid var(--av-line) !important; + color:var(--av-fg) !important; border-radius:8px; transition:border-color .12s; + } + body.admin-page-v2 .list-item:hover{ border-color:var(--av-fg-faint) !important; } + body.admin-page-v2 .list-item.selected-item{ + border-color:var(--av-accent) !important; background:var(--av-accent-dim) !important; + box-shadow:none !important; + } + body.admin-page-v2 .list-item small{ color:var(--av-fg-faint); } + body.admin-page-v2 .user-days{ color:var(--av-fg-faint) !important; } + + /* forms */ + body.admin-page-v2 input, + body.admin-page-v2 button, + body.admin-page-v2 textarea, + body.admin-page-v2 select{ + background:var(--av-panel); border:1px solid var(--av-line); color:var(--av-fg); + border-radius:8px; font:400 13.5px/1.4 "Ubuntu",sans-serif; + } + body.admin-page-v2 input::placeholder, + body.admin-page-v2 textarea::placeholder{ color:var(--av-fg-faint); } + body.admin-page-v2 input:focus, + body.admin-page-v2 textarea:focus, + body.admin-page-v2 select:focus{ outline:none; border-color:var(--av-accent); } + body.admin-page-v2 input[readonly]{ opacity:.6; cursor:default; } + body.admin-page-v2 button{ + background:var(--av-accent); color:var(--av-accent-ink); border:none; + font-weight:700; cursor:pointer; padding:8px 16px; + } + body.admin-page-v2 button:hover{ filter:brightness(1.06); } + body.admin-page-v2 .field-col > span{ + font:600 11.5px/1 "Ubuntu",sans-serif; color:var(--av-fg-faint); + } + body.admin-page-v2 .field-help{ color:var(--av-fg-faint); } + body.admin-page-v2 .actions{ flex-wrap:wrap; } + + /* acl / category grids */ + body.admin-page-v2 .acl-grid label, + body.admin-page-v2 .compact-grid label{ + display:flex; align-items:center; gap:8px; padding:6px 8px; + border:1px solid var(--av-line); border-radius:7px; background:var(--av-panel); + font:400 13px/1.3 "Ubuntu",sans-serif; color:var(--av-fg-dim); + } + body.admin-page-v2 .acl-grid input[type=checkbox], + body.admin-page-v2 .compact-grid input[type=checkbox]{ accent-color:var(--av-accent); } + body.admin-page-v2 .acl-owner{ color:var(--av-accent) !important; } + + /* tables */ + body.admin-page-v2 .admin-table{ color:var(--av-fg-dim); } + body.admin-page-v2 .admin-table th{ + color:var(--av-fg-faint); font:700 10.5px/1 "Ubuntu Mono",monospace; letter-spacing:.06em; + text-transform:uppercase; border-bottom:1px solid var(--av-line) !important; + } + body.admin-page-v2 .admin-table td{ border-bottom:1px solid var(--av-line) !important; } + body.admin-page-v2 .container-table-wrap{ + background:var(--av-panel) !important; border:1px solid var(--av-line) !important; + border-radius:10px; + } + + /* summary strip */ + body.admin-page-v2 .summary-card{ + background:var(--av-panel) !important; border:1px solid var(--av-line); + border-radius:10px; + } + body.admin-page-v2 .summary-label{ + color:var(--av-fg-faint) !important; font:600 10.5px/1 "Ubuntu Mono",monospace; + letter-spacing:.06em; text-transform:uppercase; + } + body.admin-page-v2 .summary-value{ + color:var(--av-fg) !important; font:700 19px/1 "Ubuntu Mono",monospace; margin-top:6px; + font-variant-numeric:tabular-nums; + } + + /* health / icon boxes */ + body.admin-page-v2 .health-box, + body.admin-page-v2 .icon-box{ + background:var(--av-raised) !important; border:1px solid var(--av-line) !important; + border-radius:10px; + } + body.admin-page-v2 .service-thumb, + body.admin-page-v2 .service-icon-preview{ + background:var(--av-panel) !important; border:1px solid var(--av-line-soft) !important; + border-radius:9px; + } + body.admin-page-v2 .status-ok{ background:var(--av-good) !important; } + body.admin-page-v2 .status-degraded{ background:var(--av-warn) !important; } + body.admin-page-v2 .status-down{ background:var(--av-bad) !important; } + + /* categories tab rows */ + body.admin-page-v2 .category-item-row small{ color:var(--av-fg-faint); } + body.admin-page-v2 .category-item-row button{ + background:transparent; border:1px solid var(--av-bad); color:var(--av-bad); + font-weight:600; padding:6px 12px; + } + body.admin-page-v2 .category-item-row button:hover{ background:rgba(255,107,107,.12); } + + body.admin-page-v2 .muted{ color:var(--av-fg-faint) !important; } + body.admin-page-v2 hr{ border:none; border-top:1px solid var(--av-line); margin:1.2rem 0; } +</style> </head> -<body> +<body class="admin-page-v2"> <header class="header"> <div style="display:flex; align-items:center; gap:0.6rem;"> <a href="https://4mont.ru"><img src="/static/logo.png?v=4" alt="MONT" class="header-logo" /></a> diff --git a/app/templates/bundles.html b/app/templates/bundles.html new file mode 100644 index 0000000..dbb9346 --- /dev/null +++ b/app/templates/bundles.html @@ -0,0 +1,207 @@ +<!doctype html> +<html lang="ru"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Готовые связки — MONT инфраструктурный полигон + + + + + + + + + + + + + + +
    + MONT +
    🖥️
    +
    Только для компьютера
    +
    Инфраструктурный полигон MONT оптимизирован для работы на ПК.
    Пожалуйста, откройте портал с настольного компьютера или ноутбука.
    +
    + + Минимальная ширина экрана: 1024 px +
    + +
    + +
    + + +
    +
    +
    +
    {{ ((user.first_name[0] if user.first_name else user.username[0]) + (user.last_name[0] if user.last_name else ''))|upper }}
    +
    +
    {{ (user.first_name + ' ' + user.last_name)|trim or user.username }}
    +
    оператор стенда
    +
    +
    +
    + {% if user.is_admin %} + Администрирование + {% endif %} +
    + +
    +
    +
    + +
    +

    MONT · Готовые связки

    +

    Комплексные инфраструктуры

    +

    Заказчику редко нужен один продукт в вакууме — обычно решение должно встроиться в существующую или новую инфраструктуру. Мы собираем на полигоне связку из нескольких продуктов, имитирующую реальный сценарий внедрения, чтобы показать не «продукт», а готовое решение задачи. Раздел готовится — ниже сценарии, которые уже прорабатываются.

    +
    + +
    +

    В подготовке

    +
    +
    + Готовится +
    Миграция домена
    +
    ALDpro в связке с рабочими станциями на Astra Linux, РЕД ОС и Альт Рабочая станция — вход, групповые политики и совместная работа каталога с разными ОС на местах.
    +
    +
    + Готовится +
    VDI-инфраструктура
    +
    Termidesk поверх Ред Виртуализация — типовой сценарий виртуальных рабочих столов, от сервера виртуализации до подключения пользователя.
    +
    +
    + Готовится +
    Резервное копирование виртуальной инфраструктуры
    +
    RuBackup или Vinchin в связке с Альт Виртуализация (PVE) — бэкап и восстановление виртуальных машин на живом кластере.
    +
    +
    + Готовится +
    Импортозамещение рабочего места целиком
    +
    ОС (Astra Linux / РЕД ОС / Альт Рабочая станция) + офисный пакет (АльтерОфис / P7-КС2024) + почта (CommuniGate Pro / RuPost) — полный стек рабочего места сотрудника.
    +
    +
    + Готовится +
    Контейнеризированное приложение с базой данных
    +
    Deckhouse Platform (Kubernetes) + Postgres Pro или Tantor — развёртывание приложения с реальной СУБД под капотом.
    +
    +
    + Готовится +
    Наблюдаемость виртуальной инфраструктуры
    +
    Astra Мониторинг поверх Ред Виртуализация или Альт Виртуализация (PVE) — контроль состояния кластера и виртуальных машин.
    +
    +
    +
    + + +
    +
    + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 5ea7be3..2d15128 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -23,18 +23,153 @@ - - -{% raw %}{% endraw %} + + :root{ + --dv-bg:#eef1f5; --dv-panel:#fff; --dv-raised:#fff; + --dv-line:#d7dee6; --dv-line-soft:#e3e8ee; + --dv-fg:#16202c; --dv-fg-dim:#5c6b7c; --dv-fg-faint:#93a1b0; + --dv-accent:#c96c14; --dv-accent-dim:rgba(201,108,20,.10); --dv-accent-ink:#2a1400; + --dv-good:#1f9d63; + } + /* style.css has several older `.dashboard-page{background:...!important}` + sky-gradient rules left over from the previous design - beat them with + !important too, otherwise they win regardless of source order. */ + .dashboard-page{background:var(--dv-bg) !important;color:var(--dv-fg); + font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif;min-height:100vh;overflow-x:hidden} + .dashboard-page *{box-sizing:border-box;min-width:0} + .dashboard-page a{color:inherit;text-decoration:none} + + .dv-shell{display:grid;grid-template-columns:248px minmax(0,1fr);min-height:100vh} + @media (max-width:980px){ .dv-shell{grid-template-columns:minmax(0,1fr)} } + + .dv-rail{border-right:1px solid var(--dv-line);padding:26px 16px 26px 24px;position:sticky;top:0; + align-self:start;height:100vh;overflow:auto;display:flex;flex-direction:column;gap:22px} + @media (max-width:980px){ .dv-rail{position:static;height:auto;border-right:none;border-bottom:1px solid var(--dv-line);padding:20px 18px} } + .dv-rail-brand{display:flex;align-items:center;gap:10px;flex-wrap:wrap} + .dv-rail-brand img{height:30px} + .dv-rail-kicker{font:700 11px/1 "Ubuntu Mono",monospace;letter-spacing:.16em;color:var(--dv-fg-faint);text-transform:uppercase;margin:0 0 8px 10px} + .dv-rail-nav{display:flex;flex-direction:column;gap:2px} + @media (max-width:980px){ .dv-rail-nav{flex-direction:row;flex-wrap:wrap;gap:6px} } + .dv-rail-link{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px; + font:500 14px/1.3 "Ubuntu",sans-serif;color:var(--dv-fg-dim);border:1px solid transparent} + .dv-rail-link:hover{background:var(--dv-panel);color:var(--dv-fg);border-color:var(--dv-line)} + .dv-rail-dot{width:6px;height:6px;border-radius:50%;background:var(--dv-fg-faint);flex-shrink:0} + .dv-rail-link:hover .dv-rail-dot{background:var(--dv-accent)} + .dv-rail-count{margin-left:auto;font:600 11px/1 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)} + .dv-rail-link.active{background:var(--dv-accent-dim);border-color:var(--dv-accent);color:var(--dv-fg)} + .dv-rail-link.active .dv-rail-dot{background:var(--dv-accent)} + .dv-rail-link.active .dv-rail-count{color:var(--dv-accent)} + .dv-rail-foot{margin-top:auto;font:400 12px/1.6 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)} + @media (max-width:980px){ .dv-rail-foot{display:none} } + + .dv-main{padding:0 clamp(18px,3vw,48px) 70px;min-width:0} + .dv-topbar{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:20px 0; + border-bottom:1px solid var(--dv-line);flex-wrap:wrap} + .dv-who{display:flex;align-items:center;gap:10px} + .dv-who-avatar{width:32px;height:32px;border-radius:8px;background:var(--dv-raised);border:1px solid var(--dv-line); + display:flex;align-items:center;justify-content:center;font:700 13px/1 "Ubuntu Mono",monospace;color:var(--dv-accent)} + .dv-who-name{font:500 14.5px/1.2 "Ubuntu",sans-serif} + .dv-who-role{font:400 12px/1.2 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)} + .dv-topbar-actions{display:flex;gap:8px} + .dv-btn{font:600 13px/1 "Ubuntu",sans-serif;padding:9px 16px;border-radius:7px;border:1px solid var(--dv-line); + background:var(--dv-panel);color:var(--dv-fg-dim);cursor:pointer} + .dv-btn:hover{color:var(--dv-fg);border-color:var(--dv-fg-faint)} + + .dv-hero{padding:32px 0 30px;border-bottom:1px solid var(--dv-line)} + .dv-hero-eyebrow{font:700 12.5px/1 "Ubuntu Mono",monospace;letter-spacing:.16em;text-transform:uppercase;color:var(--dv-accent);margin:0 0 10px} + .dv-hero h1{font:800 clamp(28px,3.8vw,40px)/1.1 "Ubuntu",sans-serif;letter-spacing:-.01em;margin:0 0 4px;overflow-wrap:break-word} + .dv-filter-note{font:400 13.5px/1.4 "Ubuntu",sans-serif;color:var(--dv-fg-faint);margin:10px 0 0} + .dv-filter-note b{color:var(--dv-accent)} + .dv-filter-note a{margin-left:8px;text-decoration:underline;color:var(--dv-fg-faint)} + .dv-readouts{display:flex;flex-wrap:wrap;gap:0;border:1px solid var(--dv-line);border-radius:10px;overflow:hidden;width:fit-content;margin-top:16px} + .dv-readout{padding:12px 22px;border-right:1px solid var(--dv-line)} + .dv-readout:last-child{border-right:none} + .dv-readout-val{font:700 23px/1 "Ubuntu Mono",monospace;font-variant-numeric:tabular-nums} + .dv-readout-label{font:600 10.5px/1 "Ubuntu Mono",monospace;letter-spacing:.08em;text-transform:uppercase;color:var(--dv-fg-faint);margin-top:6px} + + .dv-section{padding:36px 0 6px} + .dv-section-head{display:flex;align-items:baseline;gap:12px;margin-bottom:16px} + .dv-section-head h2{font:700 13.5px/1 "Ubuntu Mono",monospace;letter-spacing:.1em;text-transform:uppercase;margin:0} + .dv-section-head::after{content:"";flex:1;height:1px;background:var(--dv-line)} + .dv-section-count{font:600 12px/1 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)} + .dv-empty{padding:18px 0;color:var(--dv-fg-faint);font:400 13px/1.5 "Ubuntu",sans-serif} + + .dv-tile-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px} + .dv-tile{position:relative;background:var(--dv-panel);border:1px solid var(--dv-line);border-radius:12px; + padding:16px;display:flex;flex-direction:column;gap:10px;height:266px; + transition:transform .15s,border-color .15s} + .dv-tile:hover{border-color:var(--dv-accent);transform:translateY(-2px)} + .dv-tile-hit{position:absolute;inset:0;z-index:0;border-radius:12px} + .dv-tile-top{display:flex;align-items:center;gap:12px;position:relative;z-index:1;pointer-events:none} + .dv-tile-plate{flex:none;width:84px;height:84px;border-radius:14px;background:var(--dv-raised);border:1px solid var(--dv-line-soft); + display:flex;align-items:center;justify-content:center} + .dv-tile-plate img{width:60px;height:60px;object-fit:contain} + .dv-tile-name{font:600 15px/1.3 "Ubuntu",sans-serif;overflow-wrap:break-word} + .dv-tile-desc{ + margin:0;font:400 13.5px/1.55 "Ubuntu",sans-serif;color:var(--dv-fg-dim);position:relative;z-index:1; + flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;pointer-events:auto;padding-right:2px; + } + .dv-tile-desc p{margin:0} + .dv-tile-desc::-webkit-scrollbar{width:5px} + .dv-tile-desc::-webkit-scrollbar-thumb{background:var(--dv-line);border-radius:3px} + .dv-tile-desc::-webkit-scrollbar-thumb:hover{background:var(--dv-fg-faint)} + .dv-tile-enter{ + position:relative;z-index:2;margin-top:auto;align-self:flex-start;padding:7px 14px;border-radius:7px; + border:1px solid var(--dv-line);background:transparent;color:var(--dv-fg-dim);font:600 12.5px/1 "Ubuntu",sans-serif;cursor:pointer; + } + .dv-tile:hover .dv-tile-enter{border-color:var(--dv-accent);color:var(--dv-accent)} + .dv-tile-led{position:absolute;top:14px;right:14px;width:6px;height:6px;border-radius:50%;background:var(--dv-good);box-shadow:0 0 6px var(--dv-good);z-index:1} + + .dv-tile.locked{opacity:.6;filter:grayscale(.4)} + .dv-tile.locked:hover{border-color:var(--dv-line);transform:none} + .dv-tile.locked .dv-tile-plate img{filter:grayscale(1)} + .dv-tile-lock{position:absolute;top:14px;right:14px;font-size:13px;opacity:.7;z-index:1} + .dv-tile-request{ + position:relative;z-index:2;margin-top:auto;align-self:flex-start;padding:7px 14px;border-radius:7px; + border:1px dashed var(--dv-fg-faint);background:transparent;color:var(--dv-fg-faint);font:600 12.5px/1 "Ubuntu",sans-serif;cursor:pointer; + } + .dv-tile-request:hover{border-color:var(--dv-accent);border-style:solid;color:var(--dv-accent)} + + .dv-page-footer{margin-top:50px;padding-top:20px;border-top:1px solid var(--dv-line);display:flex; + justify-content:space-between;flex-wrap:wrap;gap:8px;font:400 12px/1 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)} + + /* --- request-more-access modal --- */ + .req-modal-overlay{display:none;position:fixed;inset:0;z-index:9000;background:rgba(6,9,14,.72); + align-items:center;justify-content:center;padding:1rem} + .req-modal-overlay.open{display:flex} + .req-modal{background:var(--dv-panel);border:1px solid var(--dv-line);border-radius:14px;width:100%;max-width:420px; + box-shadow:0 24px 80px rgba(0,0,0,.45);padding:22px 22px 20px} + .req-modal h3{margin:0 0 4px;font:700 17px/1.3 "Ubuntu",sans-serif} + .req-modal .sub{margin:0 0 16px;font:400 12.5px/1.5 "Ubuntu",sans-serif;color:var(--dv-fg-faint)} + .req-list{display:flex;flex-direction:column;gap:8px;margin-bottom:16px;max-height:220px;overflow-y:auto} + .req-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--dv-line);border-radius:8px;font:500 13px/1.3 "Ubuntu",sans-serif} + .req-item input{accent-color:var(--dv-accent);width:15px;height:15px} + .req-note{width:100%;box-sizing:border-box;background:var(--dv-raised);border:1px solid var(--dv-line);border-radius:8px; + padding:9px 11px;color:var(--dv-fg);font:400 13px/1.5 "Ubuntu",sans-serif;resize:vertical;min-height:56px;margin-bottom:16px} + .req-note::placeholder{color:var(--dv-fg-faint)} + .req-actions{display:flex;justify-content:flex-end;gap:10px} + .req-cancel{padding:9px 16px;border-radius:8px;border:1px solid var(--dv-line);background:transparent;color:var(--dv-fg-dim);font:600 12.5px/1 "Ubuntu",sans-serif;cursor:pointer} + .req-submit{padding:9px 18px;border-radius:8px;border:none;background:var(--dv-accent);color:var(--dv-accent-ink);font:700 12.5px/1 "Ubuntu",sans-serif;cursor:pointer} + .req-submit:disabled{opacity:.55;cursor:default} + .req-error{background:rgba(255,107,107,.12);border:1px solid rgba(255,107,107,.35);color:#ff6b6b; + border-radius:8px;padding:8px 11px;font:500 12.5px/1.4 "Ubuntu",sans-serif;margin-bottom:14px;display:none} + .req-success{text-align:center;padding:10px 0 4px} + .req-success .ico{font-size:30px;margin-bottom:10px;color:var(--dv-good)} + .req-success h4{margin:0 0 6px;font:700 16px/1.3 "Ubuntu",sans-serif} + .req-success p{margin:0;font:400 12.5px/1.5 "Ubuntu",sans-serif;color:var(--dv-fg-dim)} + + + +
    MONT
    🖥️
    @@ -47,176 +182,241 @@
    -
    -
    -
    {{ ((user.first_name[0] if user.first_name else user.username[0]) + (user.last_name[0] if user.last_name else ''))|upper }}
    - {{ (user.first_name + ' ' + user.last_name)|trim or user.username }} +
    +
    -
    - -
    -
    -
    -
    Инфраструктурный полигон MONT
    - {% if session_notice %} -
    {{ session_notice }}
    - {% endif %} -
    -
    -
    Правила работы стенда
    -
    -
    -
    Лимит: до 4 сервисов одновременно. При открытии нового сверх лимита самый старый закрывается автоматически.
    -
    При бездействии более 5 минут сессия закрывается автоматически.
    -
    Все сервисы работают в защищённом контуре с резервированием и бэкапами.
    -
    Состояние сервисов возвращается к базовому каждую ночь в 00:00.
    -
    -
    - +
    SESSION LIMIT: {{ max_active_services }}
    IDLE TIMEOUT: {{ idle_timeout_min }} MIN
    RESET: 00:00 MSK
    + + +
    +
    +
    +
    {{ ((user.first_name[0] if user.first_name else user.username[0]) + (user.last_name[0] if user.last_name else ''))|upper }}
    +
    +
    {{ (user.first_name + ' ' + user.last_name)|trim or user.username }}
    +
    оператор стенда
    - {% if categories %} -
    - Все сервисы - {% for category in categories %} - {{ category.name }} +
    + {% if user.is_admin %} + Администрирование + {% endif %} +
    + +
    +
    +
    + +
    +

    MONT · Испытательный контур

    +

    Инфраструктурный полигон

    + {% if session_notice %}
    {{ session_notice }}
    {% endif %} + {% if selected_category_slug %} + {% set current_cat = categories|selectattr('slug','equalto',selected_category_slug)|list|first %} +

    Показана категория «{{ current_cat.name if current_cat else selected_category_slug }}»сбросить

    + {% endif %} +
    +
    {{ '%02d'|format(services|length) }}
    Доступно
    +
    {{ '%02d'|format(categories|length) }}
    Категорий
    +
    +
    + +
    +

    Ваши сервисы

    {{ '%02d'|format(services|length) }}
    + {% if services %} +
    + {% for service in services %} +
    + +
    +
    +
    {{ service.name }}
    +
    +
    {{ service_comment_html.get(service.id, '') }}
    + Войти → + +
    {% endfor %}
    + {% else %} +
    {% if selected_category_slug %}В этой категории у вас пока нет доступных сервисов.{% else %}Вам пока не назначены сервисы — обратитесь к вашему менеджеру MONT.{% endif %}
    {% endif %}
    -
    - {% for service in services %} - {% set svc_cats = service_categories.get(service.id, []) %} -
    - -
    -
    - icon + + {% if locked_services %} +
    +

    Доступно по запросу

    {{ '%02d'|format(locked_services|length) }}
    +
    + {% for service in locked_services %} +
    +
    +
    +
    {{ service.name }}
    -

    {{ service.name }}

    -
    - {% if service.comment %} -
    {{ service_comment_html.get(service.id, '') }}
    - {% endif %} -
    - {% if svc_cats %} -
    - {% for category in svc_cats %} - {{ category.name }} - {% endfor %} -
    - {% endif %} +
    {{ service_comment_html.get(service.id, '') }}
    + + 🔒
    + {% endfor %}
    - {% else %} -
    - {% if selected_category_slug %} - Нет сервисов в выбранной категории - {% else %} - Нет назначенных сервисов - {% endif %} -
    - {% endfor %}
    - + {% endif %} + +
    - -
    -
    -
    Ожидайте...
    +
    + +
    +
    +

    Запросить доступ

    +

    Заявка уйдёт администратору полигона. После одобрения продукт появится у вас автоматически — заново логиниться не нужно.

    +
    +
    + +
    + + +
    - + } + document.getElementById('req-submit').addEventListener('click', submitRequest); + })(); + diff --git a/app/templates/login.html b/app/templates/login.html index 4cf28de..c288a68 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -41,10 +41,6 @@ - + - -