diff --git a/backend/atlas_portal/rate_limit.py b/backend/atlas_portal/rate_limit.py index 9cbc98d..841e308 100644 --- a/backend/atlas_portal/rate_limit.py +++ b/backend/atlas_portal/rate_limit.py @@ -4,18 +4,18 @@ import time from . import settings -_ACCESS_REQUEST_RATE: dict[str, list[float]] = {} +_RATE_BUCKETS: dict[str, dict[str, list[float]]] = {} -def rate_limit_allow(ip: str) -> bool: - if settings.ACCESS_REQUEST_RATE_LIMIT <= 0: +def rate_limit_allow(ip: str, *, key: str, limit: int, window_sec: int) -> bool: + if limit <= 0: return True now = time.time() - window_start = now - settings.ACCESS_REQUEST_RATE_WINDOW_SEC - bucket = _ACCESS_REQUEST_RATE.setdefault(ip, []) + window_start = now - window_sec + buckets_by_ip = _RATE_BUCKETS.setdefault(key, {}) + bucket = buckets_by_ip.setdefault(ip, []) bucket[:] = [t for t in bucket if t >= window_start] - if len(bucket) >= settings.ACCESS_REQUEST_RATE_LIMIT: + if len(bucket) >= limit: return False bucket.append(now) return True - diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 76272c7..bd503a9 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -37,7 +37,12 @@ def register(app) -> None: return jsonify({"error": "server not configured"}), 503 ip = request.remote_addr or "unknown" - if not rate_limit_allow(ip): + if not rate_limit_allow( + ip, + key="access_request_submit", + limit=settings.ACCESS_REQUEST_SUBMIT_RATE_LIMIT, + window_sec=settings.ACCESS_REQUEST_SUBMIT_RATE_WINDOW_SEC, + ): return jsonify({"error": "rate limited"}), 429 username, email, note = _extract_request_payload() @@ -108,7 +113,12 @@ def register(app) -> None: return jsonify({"error": "server not configured"}), 503 ip = request.remote_addr or "unknown" - if not rate_limit_allow(ip): + if not rate_limit_allow( + ip, + key="access_request_status", + limit=settings.ACCESS_REQUEST_STATUS_RATE_LIMIT, + window_sec=settings.ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC, + ): return jsonify({"error": "rate limited"}), 429 payload = request.get_json(silent=True) or {} @@ -119,11 +129,19 @@ def register(app) -> None: try: with connect() as conn: row = conn.execute( - "SELECT status FROM access_requests WHERE request_code = %s", + "SELECT status, username FROM access_requests WHERE request_code = %s", (code,), ).fetchone() if not row: return jsonify({"error": "not found"}), 404 - return jsonify({"ok": True, "status": row["status"] or "unknown"}) + status = row["status"] or "unknown" + response: dict[str, Any] = { + "ok": True, + "status": status, + "username": row.get("username") or "", + } + if status == "approved": + response["onboarding_url"] = f"/onboarding?code={code}" + return jsonify(response) except Exception: return jsonify({"error": "failed to load status"}), 502 diff --git a/backend/atlas_portal/routes/account.py b/backend/atlas_portal/routes/account.py index c26854e..0bb514a 100644 --- a/backend/atlas_portal/routes/account.py +++ b/backend/atlas_portal/routes/account.py @@ -19,7 +19,26 @@ def register(app) -> None: return resp username = g.keycloak_username - mailu_username = f"{username}@{settings.MAILU_DOMAIN}" if username else "" + mailu_username = "" + if g.keycloak_email and g.keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"): + mailu_username = g.keycloak_email + elif username: + mailu_username = f"{username}@{settings.MAILU_DOMAIN}" + + mailu_app_password = "" + if admin_client().ready() and username: + try: + user = admin_client().find_user(username) + user_id = (user or {}).get("id") or "" + if user_id: + full = admin_client().get_user(str(user_id)) + attrs = full.get("attributes") or {} + if isinstance(attrs, dict): + values = attrs.get("mailu_app_password") or [] + if isinstance(values, list) and values: + mailu_app_password = str(values[0]) + except Exception: + mailu_app_password = "" mailu_status = "ready" jellyfin_status = "ready" @@ -31,7 +50,7 @@ def register(app) -> None: return jsonify( { "user": {"username": username, "email": g.keycloak_email, "groups": g.keycloak_groups}, - "mailu": {"status": mailu_status, "username": mailu_username}, + "mailu": {"status": mailu_status, "username": mailu_username, "app_password": mailu_app_password}, "jellyfin": {"status": jellyfin_status, "username": username}, } ) @@ -79,4 +98,3 @@ def register(app) -> None: best_effort_post(settings.JELLYFIN_SYNC_URL) return jsonify({"password": password}) - diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index 7751773..8105bde 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -61,6 +61,14 @@ PORTAL_ADMIN_GROUPS = [g.strip() for g in os.getenv("PORTAL_ADMIN_GROUPS", "admi ACCESS_REQUEST_ENABLED = _env_bool("ACCESS_REQUEST_ENABLED", "true") ACCESS_REQUEST_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_RATE_LIMIT", "5")) ACCESS_REQUEST_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_RATE_WINDOW_SEC", str(60 * 60))) +ACCESS_REQUEST_SUBMIT_RATE_LIMIT = int( + os.getenv("ACCESS_REQUEST_SUBMIT_RATE_LIMIT", str(ACCESS_REQUEST_RATE_LIMIT)) +) +ACCESS_REQUEST_SUBMIT_RATE_WINDOW_SEC = int( + os.getenv("ACCESS_REQUEST_SUBMIT_RATE_WINDOW_SEC", str(ACCESS_REQUEST_RATE_WINDOW_SEC)) +) +ACCESS_REQUEST_STATUS_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_STATUS_RATE_LIMIT", "60")) +ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC", "60")) MAILU_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev") MAILU_SYNC_URL = os.getenv( diff --git a/frontend/src/router.js b/frontend/src/router.js index 039941b..6e0605f 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -7,6 +7,7 @@ import MoneroView from "./views/MoneroView.vue"; import AppsView from "./views/AppsView.vue"; import AccountView from "./views/AccountView.vue"; import RequestAccessView from "./views/RequestAccessView.vue"; +import OnboardingView from "./views/OnboardingView.vue"; export default createRouter({ history: createWebHistory(), @@ -20,5 +21,6 @@ export default createRouter({ { path: "/apps", name: "apps", component: AppsView }, { path: "/account", name: "account", component: AccountView }, { path: "/request-access", name: "request-access", component: RequestAccessView }, + { path: "/onboarding", name: "onboarding", component: OnboardingView }, ], }); diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index 0d62ee4..e568a79 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -46,6 +46,22 @@ +