Commit Graph

70 Commits

Author SHA1 Message Date
ruslan 008b99a85f Redirect to login instead of raw 401 JSON on expired session
A page tab left open past COOKIE_MAX_AGE and then reloaded hit
require_user/require_admin raising before the route body ever ran,
so there was nowhere to catch it and show something friendlier - the
browser just rendered {"detail": "Unauthorized"}. A global exception
handler now redirects GET page loads (never /api/* calls, whose JS
callers parse and handle the JSON error themselves, and never
POST/PUT/DELETE, so a failed login or CSRF check still reports its
own error instead of silently bouncing) with a 401/403 back to /.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 11:14:46 +00:00
ruslan cfd337a673 Auto-generate and email passwords - admin never types/sees one
The admin password field is gone from both the create-user and
edit-user forms. Three flows now generate a password and mail it
instead of letting an admin type one:

- create_user(): password generated, welcome email sent
  (login/password/portal link) right after the row commits
- new POST /api/admin/users/{id}/reset-password: generates a new
  password, saves it, emails it - the only way a password changes now
- assign_rdp_slot(): sending a user their first grant on a pilot
  (not every later tweak of an existing grant's expiry - guarded by
  an is_new_grant check) sends a short "you have pilot access" email,
  no credentials since the account already exists

If an email fails to send, the plaintext password comes back in the
API response as a one-time fallback so the admin isn't locked out of
handing it over by hand - the frontend only shows it then, never on
a successful send.

Added two small helpers (_build_light_email, _credentials_table) so
these three new templates share the light-theme card shell instead
of re-typing it; the existing access-approval emails keep their own
inline copies untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 07:21:33 +00:00
ruslan 684be97041 Pilots: one form (add/edit merged), inline deactivate, unify access grant with slot assign
Admin Pilots tab:
- list and create/edit form merged into one - "+ Добавить пилота" at
  the top of the list clears the form into create mode, clicking a
  pilot switches it into edit mode (title, Delete button and icon
  box toggle accordingly) instead of keeping two separate stacked
  forms
- each list row gets an inline on/off toggle (⏻/▶) to flip active
  without opening the form
- categories and the login/password hint field removed from the
  pilot form per earlier feedback - not applicable to pilots

Access model: assigning a pilot's RDP slot to a user is now the
single action that grants them the pilot - it creates/updates their
UserServiceAccess row (with the optional expiry typed right next to
the assign dropdown) in the same call, and unassigning revokes it.
Previously slot assignment and the ACL grant were two separate steps
an admin could forget to pair up, leaving a user "granted" with no
working slot or a slot with no visible access.

Consequently: pilots are no longer listed in the Users tab's ACL
grid at all - the Pilots tab is now the only place pilot access is
managed. set_acl() was fixed to never touch pilot grants regardless
of what's posted (it used to free a user's pilot slot whenever it
saw a pilot id missing from service_ids - which happens on every
save now that the grid never includes pilots, so saving any other
product's ACL would have silently stripped every pilot grant).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 07:07:38 +00:00
ruslan ae630f9115 Pilots: separate admin tab, collapsible dashboard section
Admin: moved pilot management out of the RDP tab into its own
"Pilots" tab - dedicated list, create/edit form and slot table with
a per-slot user-assign dropdown (always on, no more is_pilot
checkbox toggling the RDP tab's slot table between two modes). The
RDP tab goes back to exactly its pre-pilots shape.

Dashboard: a granted pilot no longer sits inside the "Ваши сервисы"
grid - it gets its own <details> section above it, open by default
whenever the user has at least one pilot (and simply absent when
they have none, rather than showing empty and collapsed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 06:51:18 +00:00
ruslan 46e5b5a41e Add pilots: invite-only products with per-user RDP slots
New Service.is_pilot flag - a pilot is a Service (type RDP) that:
- is excluded from /api/public/services-by-category and
  /api/request-more-access (admin-granted only, no self-service)
- still shows as a locked card on the dashboard for users without
  access (Доступно по запросу section), but with a По приглашению
  badge instead of the self-request button/flow
- gets its own non-clickable teaser row on the public /login page
  (logos only, informational)

RdpSlot.assigned_user_id (nullable) - pilot slots are bound to one
specific user instead of being drawn from the shared pool; regular
RDP services are unaffected (field stays NULL, same pool behaviour
as before). The /go/ allocator branches on service.is_pilot to pick
the caller's assigned slot instead of any free one. Slots release
automatically (cleanup_loop) when the owning grant is revoked or
expires, and immediately on manual ACL revoke.

UserServiceAccess.expires_at (nullable) - per-grant access window,
used by pilots so their access can be shorter than the account's own
expires_at; NULL (unchanged default) means "follow the account".
has_access() and the dashboard's granted/locked split both honour it.

Admin UI: "Это пилот" checkbox on the RDP service form, an
assign-user dropdown on a pilot's slot table (replaces the
occupied-by column), and a per-pilot expiry date field in the user
ACL grid.

Schema is applied via the existing ensure_schema_compatibility()
idempotent ALTER TABLE pattern (no alembic in this project) - no
manual migration step needed, it runs at container startup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 06:32:05 +00:00
ruslan 9e525a44c3 Light-theme access emails + inline cid logo instead of broken data-URI
Outlook desktop (Word rendering engine) does not support data: URI
images at all - the logo in approval/rejection emails was silently
dropped there. Fix:

- app/static/logo-email.png: logo asset, attached as an inline MIME
  part with Content-ID instead of base64-embedded in <img src>
- app/main.py _send_email(): builds a multipart/related message and
  attaches the logo as cid:mont_logo whenever html_body references it
- all 6 client-facing access emails (approve/reject x 3 call sites):
  switched from dark gradient theme to a light theme (solid white
  card, dark text) - Outlook does not support CSS gradients either,
  so the old dark card + light text would have rendered as invisible
  light text on a default white background on top of the missing logo
- footer contact address: mont@mont.ru -> RGalyaviev@mont.ru

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 06:01:00 +00:00
ruslan 40de4d5a0f Bundles feature WIP + fix request-access products cap (20 -> 200)
- app/main.py: raise products_raw[:20] cap to [:200] in /api/request-access
  and /api/request-more-access - was silently truncating access requests
  with more than 20 selected products
- accumulated bundles/product-page WIP (config, models, runtime, templates)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 13:58:09 +00:00
ruslan 3251ce3380 Fix favicon HEAD 405 and unify favicon design across ico/png/svg 2026-08-03 13:00:29 +00:00
ruslan 6f7f28408d fix: iframe probe accepts only 2xx (reject portal 404 with X-Frame-Options) 2026-07-23 12:24:20 +00:00
ruslan c3efdc5a8f security: add brute-force rate limiting to /login
5 failed attempts per IP within 5 minutes triggers 15-minute block.
Counter resets on successful login. State is per-worker (in-memory).
2026-07-23 11:41:08 +00:00
ruslan 3092645408 security: disable FastAPI docs/redoc/openapi endpoints in production 2026-07-23 11:38:13 +00:00
ruslan 1e6eadeed2 refactor: remove dead code (safe cleanup)
- main.py: remove duplicate sqlalchemy select import
- main.py: remove unused json import in _telegram_poll_loop
- runtime.py: remove unreachable return after raise LockTimeoutError
- runtime.py: remove _restart_rdp_slot_bg (defined but never called)
2026-07-23 11:07:48 +00:00
ruslan 9839539206 fix: double-check iframe src with HEAD probe before loading
After status/ready=true, do HEAD request to the iframe URL itself.
If it returns 5xx, keep polling. This prevents Bad Gateway appearing
in the iframe when container is technically registered but not yet serving.
2026-07-23 10:55:42 +00:00
ruslan a1479aa47a fix: view page polls readiness before loading iframe
Session view page was loading the iframe immediately on load, causing
Bad Gateway if the container was still starting. Now polls /status
until ready, then sets iframe src.
2026-07-23 10:50:41 +00:00
ruslan b2297c7773 fix: route_ready rejects 5xx responses; remove spinner from wait page
route_ready returned True on 502 Bad Gateway, causing premature redirect
before the container was actually serving. Now only accepts < 500 responses.
Spinner removed from wait page per user request.
2026-07-23 10:48:16 +00:00
ruslan b89f239d02 feat: add spinner and loading message to session wait page
Wait page was a blank dark screen - users saw nothing and hit refresh.
Now shows spinner + service name + status message from /status endpoint.
2026-07-23 10:43:57 +00:00
ruslan 3eff94fbb2 ui: remove credentials badge from WEB sessions, remove Back button from RDP
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 11:07:21 +00:00
ruslan 65b098b702 fix: serve favicon.ico from /favicon.ico route with correct mime type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:32:04 +00:00
ruslan b7a5d42644 fix: serve /favicon.ico so search engines find the site icon 2026-07-20 19:37:09 +00:00
ruslan 709de88c95 fix: encode From header with RFC 2047 to fix email delivery with Cyrillic sender name 2026-07-20 17:06:52 +00:00
ruslan 806c5d8e1f session wait page: replace visible card with silent dark redirect 2026-07-20 16:04:21 +00:00
ruslan 3433e32df3 Handle renewal: extend expiry, add new products, send renewal email 2026-07-08 08:56:53 +00:00
ruslan 4b4b07dd20 Fix Telegram sendMessage: switch to HTML parse mode, escape user data 2026-07-03 11:37:30 +00:00
ruslan efa1c26e5d Email improvements: domain-aware portal URL, embedded logo, fix product list color
- Store request origin domain in PendingAccessRequest.portal_url
- Use per-request portal URL in approval/rejection emails
- Embed logo as base64 so it displays without external image loading
- Fix 'Предоставлен доступ к продуктам' text color to match body color
- Switch Telegram polling to 30-second interval with single-worker flock fix
2026-05-29 16:10:40 +00:00
ruslan e5ea23487e Add Telegram approval flow: inline buttons, user creation, email notifications 2026-05-29 14:41:42 +00:00
ruslan de49bffc1b Update privacy redirect to mont.ru/ru-ru/privacy 2026-05-28 10:22:54 +00:00
ruslan 7765d666ef Replace privacy page with 301 redirect to mont.ru/ru-ru/agreement 2026-05-28 10:22:02 +00:00
ruslan e88e33e7e8 Add privacy policy page and consent checkbox to both modals (152-FZ compliance) 2026-05-28 09:44:17 +00:00
ruslan 8ab7df12a1 Replace logo.png with new version, rename МОНТ→MONT everywhere 2026-05-27 17:39:16 +00:00
ruslan 5c06440e4d Add Yandex Webmaster verification file 2026-05-15 13:02:42 +00:00
ruslan 3d531238d7 SEO: meta tags, OG, JSON-LD, robots.txt, sitemap, keywords in content 2026-05-15 12:50:55 +00:00
ruslan a4b69b0018 Fix real IP: trust upstream forwardedHeaders in Traefik, use X-Forwarded-For[0] 2026-05-14 07:41:51 +00:00
ruslan 73c7d006c7 Fix _get_real_ip: use X-Real-IP from NPM instead of X-Forwarded-For 2026-05-14 07:33:49 +00:00
ruslan 1aa9db8e2a Add real IP + geo location to Telegram notifications 2026-05-14 07:27:23 +00:00
ruslan b36b3f6325 Add contact modal, success messages, form reset on open 2026-05-14 06:42:09 +00:00
ruslan eb05bcac53 Add email and phone validation to request-access modal 2026-05-14 06:29:37 +00:00
ruslan beb2781123 Fix request-access: add Telegram env to compose, fix log_event calls 2026-05-14 06:28:58 +00:00
ruslan f740420a77 Add request access modal on login page with Telegram notification
- Modal form: name, company, email, phone (required), manager (optional), product checkboxes
- Products loaded from DB via GET /api/public/services-by-category (public route)
- POST /api/request-access sends styled Telegram message with divider and emojis
- Dark-themed modal matching login page design
- CSS: overlay, card, fields, checkbox list, error, footer buttons
2026-05-14 06:22:39 +00:00
ruslan 6aa40eb5c2 feat: add first_name/last_name to users, avatar in header, neutral dashboard bg 2026-05-12 12:51:47 +00:00
ruslan 3d8ccd30b6 fix: add hash_password to auth imports in main.py 2026-05-01 16:47:48 +00:00
ruslan dc90569631 fix: call connect_rdp_slot on session reuse
Previously connect_rdp_slot was only called when creating a new session.
If the API container restarted, existing sessions had should_be_connected=false
and xfreerdp never started. Now connect is triggered on every /go/<slug> visit
when an RDP session already exists.
2026-05-01 16:11:30 +00:00
ruslan 38dc206f5a fix: add missing user_is_valid import from auth in main.py 2026-05-01 12:56:19 +00:00
ruslan fb4af8cfe6 fix: add missing import secrets in main.py 2026-05-01 12:55:02 +00:00
ruslan 58cb8b1035 feat: on-demand RDP - connect xfreerdp only when session opens
Replaces always-on xfreerdp with on-demand model (load 12 to under 1 at idle).
- rdp-proxy/manager.py: HTTP server port 7001 managing xfreerdp lifecycle
- rdp-proxy/entrypoint.sh: starts Xvfb+x11vnc+websockify+manager, no auto-connect
- rdp-proxy/Dockerfile: adds python3, copies manager.py, exposes 7001
- runtime.py: connect_rdp_slot and disconnect_rdp_slot via manager HTTP API
- terminate_session_record: disconnect instead of container restart
- main.py: calls connect_rdp_slot in background thread on session create
- maintenance.py: cleanup_loop disconnects on expire, run_maintenance_service
  includes RDP slot init, maintenance_runner fixed to import maintenance
2026-05-01 10:12:52 +00:00
ruslan 82024a36c4 fix: add missing sqlalchemy imports (select, text, delete, update) to main.py 2026-05-01 09:51:24 +00:00
ruslan b8dd023233 fix: add missing runtime imports (route_ready, docker_client, ensure_universal_pool, get_universal_pool_status) 2026-05-01 09:48:42 +00:00
ruslan 1c7caec021 fix: add missing config imports to main.py (GO_*_LOCK_TIMEOUT, WEB_POOL_BUFFER) 2026-05-01 09:45:55 +00:00
ruslan c8c77048c7 refactor: split main.py into modules (config, database, models, utils, auth, runtime, maintenance)
main.py was ~3000 lines with models, routes, Docker ops, maintenance all mixed.
Split into 7 focused modules:
- config.py: env vars and constants
- database.py: SQLAlchemy engine, SessionLocal, Base, get_db
- models.py: ORM models and enums
- utils.py: logging, formatting, icon handling, misc helpers
- auth.py: password hashing, cookies, CSRF, user dependency
- runtime.py: all Docker operations, pool management, session lifecycle
- maintenance.py: cleanup loop, schema bootstrap, startup logic
- main.py: FastAPI app, middleware, all route handlers only
2026-05-01 09:40:06 +00:00
ruslan cf68bc848f Fix CSRF SameSite=Strict breaking login on iPad/Safari
Safari (iPadOS/iOS) blocks SameSite=Strict cookies on the initial
top-level navigation when it considers the request cross-site (links
from messengers, email, QR codes). The CSRF cookie was therefore never
set on first visit, and the subsequent login POST failed with 403
"CSRF failed".

Switch the CSRF cookie to SameSite=Lax — this is the OWASP recommended
default and matches industry practice. The auth (session) cookie keeps
SameSite=Strict, since it is only issued after a successful first-party
login POST and needs the stricter binding.
2026-04-30 17:38:20 +00:00
ruslan 23c1f6e342 Chromium: Russian language, autofill passwords from svc_login/svc_password via Login Data 2026-04-30 07:22:31 +00:00