Skip to content

Commit 35704cc

Browse files
committed
accessGuard, recap, integrations, misc changes
1 parent 851f149 commit 35704cc

31 files changed

Lines changed: 5945 additions & 511 deletions

‎Api/main.py‎

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from Api.deps.db import init_db
99
from Api.utils.auth_middleware import AuthMiddleware
10+
from Api.routers.recaps import router as recaps_router
11+
from Api.routers.access import router as access_router
1012
from Api.routers.browse import router as browse_router
1113
from Api.routers.share import router as share_router
1214
from Api.routers.auth import router as auth_router
@@ -27,16 +29,21 @@
2729
from Api.routers.yt_dlp import router as yt_dlp_router
2830
from Api.routers.sources import router as sources_router
2931
from Api.routers.topics import router as topics_router
32+
from Api.routers.discord import router as discord_router
3033

3134
from stream.core.config_manager import Config
3235

3336

3437
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
35-
DIST_DIR = os.path.join(BASE_DIR, "dist")
36-
if not os.path.exists(DIST_DIR):
37-
_web_dist = os.path.join(BASE_DIR, "StreamXWeb", "dist")
38-
if os.path.exists(_web_dist):
39-
DIST_DIR = _web_dist
38+
_webx_dist = os.path.join(BASE_DIR, "WebX", "dist")
39+
if os.path.exists(_webx_dist):
40+
DIST_DIR = _webx_dist
41+
else:
42+
DIST_DIR = os.path.join(BASE_DIR, "dist")
43+
if not os.path.exists(DIST_DIR):
44+
_web_dist = os.path.join(BASE_DIR, "StreamXWeb", "dist")
45+
if os.path.exists(_web_dist):
46+
DIST_DIR = _web_dist
4047
ASSETS_DIR = os.path.join(DIST_DIR, "assets")
4148

4249

@@ -58,21 +65,27 @@ def _get_cors_origins():
5865
if not cors:
5966
return ["*"]
6067
if isinstance(cors, list):
61-
return [str(c).strip() for c in cors if str(c).strip()]
68+
origins = [str(c).strip() for c in cors if str(c).strip()]
69+
return origins or ["*"]
6270
if isinstance(cors, str):
6371
s = cors.strip()
6472
if not s or s == "*":
6573
return ["*"]
6674
if "," in s:
67-
return [x.strip() for x in s.split(",") if x.strip()]
75+
origins = [x.strip() for x in s.split(",") if x.strip()]
76+
return origins or ["*"]
6877
return [s]
6978
return ["*"]
7079

7180
app.add_middleware(AuthMiddleware)
7281

82+
_cors_origins = _get_cors_origins()
83+
_allow_all_origins = "*" in _cors_origins
84+
7385
app.add_middleware(
7486
CORSMiddleware,
75-
allow_origins=_get_cors_origins(),
87+
allow_origins=[] if _allow_all_origins else _cors_origins,
88+
allow_origin_regex=r"^(https?://|capacitor://|tauri://|ionic://).*" if _allow_all_origins else None,
7689
allow_credentials=True,
7790
allow_methods=["*"],
7891
allow_headers=["*"],
@@ -101,14 +114,17 @@ def _get_cors_origins():
101114
app.include_router(logs_router)
102115
app.include_router(yt_dlp_router)
103116
app.include_router(sources_router)
117+
app.include_router(discord_router)
118+
app.include_router(recaps_router)
119+
app.include_router(access_router)
104120

105121

106122

107123
@app.get("/")
108124
async def serve_root():
109125
index_file = os.path.join(DIST_DIR, "index.html")
110126
if os.path.exists(index_file):
111-
return FileResponse(index_file)
127+
return FileResponse(index_file, headers={"Cache-Control": "no-cache, no-store, must-revalidate"})
112128
return {"status": "frontend not built"}
113129

114130

@@ -117,10 +133,12 @@ async def serve_spa(full_path: str):
117133
file_path = os.path.join(DIST_DIR, full_path)
118134

119135
if os.path.exists(file_path):
136+
if full_path in ("sw.js", "manifest.json"):
137+
return FileResponse(file_path, headers={"Cache-Control": "no-cache, no-store, must-revalidate"})
120138
return FileResponse(file_path)
121139

122140
index_file = os.path.join(DIST_DIR, "index.html")
123141
if os.path.exists(index_file):
124-
return FileResponse(index_file)
142+
return FileResponse(index_file, headers={"Cache-Control": "no-cache, no-store, must-revalidate"})
125143

126144
return {"status": "frontend not built"}

‎Api/routers/access.py‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Admin access control — registration policy, invites, allow list, user locking, membership sweeps."""
2+
from __future__ import annotations
3+
4+
from typing import Any, Literal, Optional
5+
6+
from fastapi import APIRouter, Depends, HTTPException, Query
7+
from pydantic import BaseModel, Field
8+
9+
from Api.services import access_control as access
10+
from Api.utils.auth import require_admin_user_id
11+
12+
router = APIRouter(prefix="/admin/access", tags=["access"])
13+
14+
15+
class RequiredChatIn(BaseModel):
16+
chat_id: int
17+
title: Optional[str] = None
18+
invite_link: Optional[str] = None
19+
is_private: bool = False
20+
21+
22+
class PolicyPatch(BaseModel):
23+
registration_mode: Optional[Literal["open", "invite", "allowlist", "closed"]] = None
24+
enforce_membership: Optional[bool] = None
25+
lock_message: Optional[str] = Field(default=None, max_length=400)
26+
required_chats: Optional[list[RequiredChatIn]] = None
27+
28+
29+
class InviteCreate(BaseModel):
30+
max_uses: int = Field(default=1, ge=0, le=10000)
31+
ttl_days: Optional[int] = Field(default=7, ge=0, le=3650)
32+
note: Optional[str] = Field(default=None, max_length=120)
33+
34+
35+
class LockBody(BaseModel):
36+
reason: Optional[str] = Field(default=None, max_length=300)
37+
revoke_sessions: bool = True
38+
39+
40+
class AllowlistBody(BaseModel):
41+
user_id: int
42+
note: Optional[str] = Field(default=None, max_length=120)
43+
44+
45+
class BypassBody(BaseModel):
46+
user_id: int
47+
note: Optional[str] = Field(default=None, max_length=120)
48+
49+
50+
@router.get("/policy")
51+
async def get_policy(_: int = Depends(require_admin_user_id)):
52+
return {"ok": True, "policy": await access.get_policy(force=True)}
53+
54+
55+
@router.patch("/policy")
56+
async def patch_policy(body: PolicyPatch, admin: int = Depends(require_admin_user_id)):
57+
patch: dict[str, Any] = {k: v for k, v in body.model_dump().items() if v is not None}
58+
try:
59+
policy = await access.update_policy(patch, by=admin)
60+
except ValueError as exc:
61+
raise HTTPException(status_code=400, detail=str(exc))
62+
return {"ok": True, "policy": policy}
63+
64+
65+
@router.post("/required-chats")
66+
async def add_required_chat(body: RequiredChatIn, _: int = Depends(require_admin_user_id)):
67+
policy = await access.add_required_chat(body.chat_id, body.title, body.invite_link, is_private=body.is_private)
68+
return {"ok": True, "policy": policy}
69+
70+
71+
@router.delete("/required-chats/{chat_id}")
72+
async def remove_required_chat(chat_id: int, _: int = Depends(require_admin_user_id)):
73+
return {"ok": True, "policy": await access.remove_required_chat(chat_id)}
74+
75+
76+
@router.get("/invites")
77+
async def invites(include_dead: bool = Query(default=False), _: int = Depends(require_admin_user_id)):
78+
return {"ok": True, "items": await access.list_invites(include_dead)}
79+
80+
81+
@router.post("/invites")
82+
async def create_invite(body: InviteCreate, admin: int = Depends(require_admin_user_id)):
83+
return {"ok": True, "invite": await access.create_invite(admin, body.max_uses, body.ttl_days or None, body.note)}
84+
85+
86+
@router.delete("/invites/{code}")
87+
async def revoke_invite(code: str, _: int = Depends(require_admin_user_id)):
88+
if not await access.revoke_invite(code):
89+
raise HTTPException(status_code=404, detail="invite not found")
90+
return {"ok": True}
91+
92+
93+
@router.get("/allowlist")
94+
async def allowlist(_: int = Depends(require_admin_user_id)):
95+
return {"ok": True, "items": await access.allowlist_list()}
96+
97+
98+
@router.post("/allowlist")
99+
async def allowlist_add(body: AllowlistBody, admin: int = Depends(require_admin_user_id)):
100+
await access.allowlist_add(body.user_id, admin, body.note)
101+
return {"ok": True}
102+
103+
104+
@router.delete("/allowlist/{user_id}")
105+
async def allowlist_remove(user_id: int, _: int = Depends(require_admin_user_id)):
106+
return {"ok": await access.allowlist_remove(user_id)}
107+
108+
109+
@router.get("/bypass")
110+
async def bypass_list(_: int = Depends(require_admin_user_id)):
111+
return {"ok": True, "items": await access.bypass_list()}
112+
113+
114+
@router.post("/bypass")
115+
async def bypass_add(body: BypassBody, admin: int = Depends(require_admin_user_id)):
116+
await access.bypass_add(body.user_id, admin, body.note)
117+
return {"ok": True}
118+
119+
120+
@router.delete("/bypass/{user_id}")
121+
async def bypass_remove(user_id: int, _: int = Depends(require_admin_user_id)):
122+
return {"ok": await access.bypass_remove(user_id)}
123+
124+
125+
@router.get("/users")
126+
async def users(
127+
q: Optional[str] = Query(default=None, max_length=64),
128+
status: Optional[Literal["active", "locked", "restricted"]] = None,
129+
limit: int = Query(default=50, ge=1, le=200),
130+
skip: int = Query(default=0, ge=0),
131+
_: int = Depends(require_admin_user_id),
132+
):
133+
return {"ok": True, **(await access.list_users(q, status, limit, skip))}
134+
135+
136+
@router.post("/users/{user_id}/lock")
137+
async def lock_user(user_id: int, body: LockBody, admin: int = Depends(require_admin_user_id)):
138+
if int(user_id) == int(admin):
139+
raise HTTPException(status_code=400, detail="You can't lock your own account")
140+
return {"ok": True, "access": await access.set_user_status(user_id, "locked", body.reason, by=admin, revoke_sessions=body.revoke_sessions)}
141+
142+
143+
@router.post("/users/{user_id}/unlock")
144+
async def unlock_user(user_id: int, _: int = Depends(require_admin_user_id)):
145+
return {"ok": True, "access": await access.set_user_status(user_id, "active")}
146+
147+
148+
@router.post("/users/{user_id}/revoke-sessions")
149+
async def revoke_sessions(user_id: int, _: int = Depends(require_admin_user_id)):
150+
return {"ok": True, "token_version": await access.revoke_sessions(user_id)}
151+
152+
153+
@router.post("/reverify")
154+
async def reverify(_: int = Depends(require_admin_user_id)):
155+
return {"ok": True, **(await access.reverify_all())}

0 commit comments

Comments
 (0)