minhvtt commited on
Commit
5b977f1
·
verified ·
1 Parent(s): 29563f9

Upload 23 files

Browse files
app/__init__.py ADDED
File without changes
app/core/auth.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Literal
2
+
3
+ from fastapi import Header, HTTPException
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class AuthUser(BaseModel):
8
+ username: str
9
+ role: Literal["admin", "sensitive_admin"]
10
+
11
+
12
+ def issue_token(user: AuthUser) -> str:
13
+ return f"dev-token::{user.username}::{user.role}"
14
+
15
+
16
+ def parse_token(token: str) -> AuthUser | None:
17
+ parts = token.split("::")
18
+ if len(parts) != 3 or parts[0] != "dev-token":
19
+ return None
20
+
21
+ username = parts[1]
22
+ role = parts[2]
23
+ if role not in {"admin", "sensitive_admin"}:
24
+ return None
25
+
26
+ return AuthUser(username=username, role=role)
27
+
28
+
29
+ def get_current_web_user(authorization: str | None = Header(default=None)) -> AuthUser:
30
+ if not authorization or not authorization.lower().startswith("bearer "):
31
+ raise HTTPException(status_code=401, detail="Missing bearer token")
32
+
33
+ token = authorization[7:]
34
+ user = parse_token(token)
35
+ if user is None:
36
+ raise HTTPException(status_code=401, detail="Invalid token")
37
+ return user
app/core/config.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pydantic import BaseModel
3
+ from pydantic_settings import BaseSettings
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ app_name: str = "G-Solution Backend"
8
+ api_prefix: str = "/api/v1"
9
+ mongo_uri: str = "mongodb://localhost:27017"
10
+ mongo_db: str = "g_solution"
11
+ storage_dir: str = "storage/screenshots"
12
+ media_prefix: str = "/media"
13
+ hf_token: str = ""
14
+
15
+ class Config:
16
+ env_file = ".env"
17
+ env_file_encoding = "utf-8"
18
+ case_sensitive = False
19
+
20
+
21
+ settings = Settings()
app/core/db.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
2
+
3
+ from app.core.config import settings
4
+
5
+ _client: AsyncIOMotorClient | None = None
6
+
7
+
8
+ def get_db() -> AsyncIOMotorDatabase:
9
+ global _client
10
+ if _client is None:
11
+ _client = AsyncIOMotorClient(settings.mongo_uri)
12
+ return _client[settings.mongo_db]
13
+
14
+
15
+ async def ensure_indexes() -> None:
16
+ db = get_db()
17
+ await db.devices.create_index("device_id", unique=True)
18
+ await db.policies.create_index("device_id", unique=True)
19
+ await db.key_states.create_index("device_id", unique=True)
20
+ await db.key_states.create_index("expires_at")
21
+ await db.key_states.create_index("lock_until")
22
+ await db.violations.create_index("device_id")
23
+ await db.violations.create_index("timestamp")
24
+ await db.violations.create_index("expires_at")
25
+ await db.screenshot_audit.create_index("created_at")
26
+ await db.alert_audit.create_index("created_at")
27
+ await db.alert_audit.create_index("type")
28
+ await db.url_verdicts.create_index("url", unique=True)
29
+ await db.url_verdicts.create_index("verdict")
30
+ await db.game_lock_reviews.create_index("review_id", unique=True)
31
+ await db.game_lock_reviews.create_index("status")
32
+ await db.game_lock_reviews.create_index("created_at")
app/main.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.staticfiles import StaticFiles
4
+
5
+ from app.core.config import settings
6
+ from app.core.db import ensure_indexes
7
+ from app.routers import alerts, auth, devices, keys, policy, reviews, screenshots
8
+
9
+ app = FastAPI(title=settings.app_name)
10
+
11
+ app.add_middleware(
12
+ CORSMiddleware,
13
+ allow_origins=["*"],
14
+ allow_credentials=True,
15
+ allow_methods=["*"],
16
+ allow_headers=["*"],
17
+ )
18
+
19
+ app.include_router(auth.router, prefix=settings.api_prefix)
20
+ app.include_router(devices.router, prefix=settings.api_prefix)
21
+ app.include_router(policy.router, prefix=settings.api_prefix)
22
+ app.include_router(screenshots.router, prefix=settings.api_prefix)
23
+ app.include_router(keys.router, prefix=settings.api_prefix)
24
+ app.include_router(alerts.router, prefix=settings.api_prefix)
25
+ app.include_router(reviews.router, prefix=settings.api_prefix)
26
+ app.mount(settings.media_prefix, StaticFiles(directory=settings.storage_dir), name="media")
27
+
28
+
29
+ @app.on_event("startup")
30
+ async def on_startup() -> None:
31
+ await ensure_indexes()
32
+
33
+
34
+ @app.get("/health")
35
+ def health() -> dict[str, str]:
36
+ return {"status": "ok"}
app/models/schemas.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Literal
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class LoginRequest(BaseModel):
8
+ username: str
9
+ password: str
10
+
11
+
12
+ class LoginResponse(BaseModel):
13
+ token: str
14
+ role: Literal["admin", "sensitive_admin"] = "admin"
15
+ expires_in_seconds: int = 3600
16
+
17
+
18
+ class RegisterDeviceRequest(BaseModel):
19
+ device_id: str
20
+ hostname: str
21
+ os_version: str
22
+ agent_version: str
23
+
24
+
25
+ class RegisterDeviceResponse(BaseModel):
26
+ registered: bool = True
27
+ session_token: str
28
+
29
+
30
+ class HeartbeatRequest(BaseModel):
31
+ device_id: str
32
+ active_restriction_window: bool
33
+ uptime_seconds: int = 0
34
+
35
+
36
+ class HeartbeatResponse(BaseModel):
37
+ accepted: bool = True
38
+ server_time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
39
+
40
+
41
+ class DeviceStatusRow(BaseModel):
42
+ device_id: str
43
+ hostname: str
44
+ agent_version: str
45
+ os_version: str
46
+ last_heartbeat_at: datetime | None = None
47
+ active_restriction_window: bool = False
48
+
49
+
50
+ class TimeWindow(BaseModel):
51
+ day_of_week: Literal["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
52
+ start: str = Field(description="HH:MM")
53
+ end: str = Field(description="HH:MM")
54
+
55
+
56
+ class DevicePolicy(BaseModel):
57
+ blocked_executables: list[str] = []
58
+ blocked_domains: list[str] = []
59
+ restriction_windows: list[TimeWindow] = []
60
+
61
+
62
+ class UpdatePolicyRequest(BaseModel):
63
+ device_id: str
64
+ policy: DevicePolicy
65
+
66
+
67
+ class ScreenshotIngestResponse(BaseModel):
68
+ screenshot_id: str
69
+ queued: bool = True
70
+
71
+
72
+ class ViolationScreenshot(BaseModel):
73
+ screenshot_id: str
74
+ device_id: str
75
+ timestamp: str
76
+ filename: str
77
+ created_at: str
78
+ image_url: str
79
+ confidence: float
80
+ reason: str
81
+ visibility_scope: Literal["admin", "sensitive_admin"] = "admin"
82
+ ocr_urls: list[str] = []
83
+
84
+
85
+ class GameReviewItem(BaseModel):
86
+ review_id: str
87
+ screenshot_id: str
88
+ device_id: str
89
+ timestamp: str
90
+ status: Literal["pending", "approved", "rejected"]
91
+ reason: str
92
+ ocr_urls: list[str] = []
93
+ created_at: datetime
94
+
95
+
96
+ class GameReviewDecisionRequest(BaseModel):
97
+ review_id: str
98
+ approve_lock: bool
99
+
100
+
101
+ class GameReviewDecisionResponse(BaseModel):
102
+ updated: bool
103
+
104
+
105
+ class KeyGenerateRequest(BaseModel):
106
+ device_id: str
107
+
108
+
109
+ class KeyGenerateResponse(BaseModel):
110
+ device_id: str
111
+ key: str
112
+ expires_at: datetime
113
+
114
+
115
+ class KeyValidateRequest(BaseModel):
116
+ device_id: str
117
+ key: str
118
+
119
+
120
+ class KeyValidateResponse(BaseModel):
121
+ valid: bool
122
+ lock_until: datetime | None = None
123
+
124
+
125
+ class AlertEvent(BaseModel):
126
+ device_id: str
127
+ type: Literal[
128
+ "wrong_key",
129
+ "policy_violation",
130
+ "process_killed",
131
+ "info",
132
+ "device_status",
133
+ "sensitive_detected",
134
+ "game_lock_review_required",
135
+ "game_lock_review_decision",
136
+ ]
137
+ message: str
138
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
139
+
140
+
141
+ def lock_until_5_minutes() -> datetime:
142
+ return datetime.now(timezone.utc) + timedelta(minutes=5)
app/routers/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from app.routers import alerts, auth, devices, keys, policy, reviews, screenshots
2
+
3
+ __all__ = ["auth", "devices", "policy", "screenshots", "keys", "alerts", "reviews"]
app/routers/alerts.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
2
+
3
+ from app.services.alert_hub import alert_hub
4
+
5
+ router = APIRouter(prefix="/alerts", tags=["alerts"])
6
+
7
+
8
+ @router.websocket("/ws/admin")
9
+ async def ws_admin(websocket: WebSocket) -> None:
10
+ await alert_hub.connect_admin(websocket)
11
+ try:
12
+ while True:
13
+ message = await websocket.receive_text()
14
+ if message == "ping":
15
+ await websocket.send_text("pong")
16
+ except WebSocketDisconnect:
17
+ alert_hub.disconnect_admin(websocket)
18
+ except Exception:
19
+ alert_hub.disconnect_admin(websocket)
20
+
21
+
22
+ @router.websocket("/ws/device/{device_id}")
23
+ async def ws_device(websocket: WebSocket, device_id: str) -> None:
24
+ await alert_hub.connect_device(device_id, websocket)
25
+ try:
26
+ while True:
27
+ message = await websocket.receive_text()
28
+ if message == "ping":
29
+ await websocket.send_text("pong")
30
+ except WebSocketDisconnect:
31
+ alert_hub.disconnect_device(device_id, websocket)
32
+ except Exception:
33
+ alert_hub.disconnect_device(device_id, websocket)
app/routers/auth.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+
3
+ from app.core.auth import AuthUser, issue_token
4
+ from app.models.schemas import LoginRequest, LoginResponse
5
+
6
+ router = APIRouter(prefix="/auth", tags=["auth"])
7
+
8
+
9
+ @router.post("/login", response_model=LoginResponse)
10
+ def login(payload: LoginRequest) -> LoginResponse:
11
+ if payload.username == "admin" and payload.password == "admin123":
12
+ user = AuthUser(username="admin", role="admin")
13
+ return LoginResponse(token=issue_token(user), role=user.role)
14
+
15
+ if payload.username == "admin_sensitive" and payload.password == "safe123":
16
+ user = AuthUser(username="admin_sensitive", role="sensitive_admin")
17
+ return LoginResponse(token=issue_token(user), role=user.role)
18
+
19
+ raise HTTPException(status_code=401, detail="Invalid credentials")
app/routers/devices.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from app.models.schemas import (
6
+ DeviceStatusRow,
7
+ HeartbeatRequest,
8
+ HeartbeatResponse,
9
+ RegisterDeviceRequest,
10
+ RegisterDeviceResponse,
11
+ )
12
+ from app.services.alert_hub import alert_hub
13
+ from app.services.repos import device_repo
14
+
15
+ router = APIRouter(prefix="/devices", tags=["devices"])
16
+
17
+
18
+ @router.post("/register", response_model=RegisterDeviceResponse)
19
+ async def register_device(payload: RegisterDeviceRequest) -> RegisterDeviceResponse:
20
+ await device_repo.upsert(
21
+ {
22
+ **payload.model_dump(),
23
+ "active_restriction_window": False,
24
+ "last_heartbeat_at": None,
25
+ "uptime_seconds": 0,
26
+ }
27
+ )
28
+ return RegisterDeviceResponse(session_token=f"session-{payload.device_id}")
29
+
30
+
31
+ @router.post("/heartbeat", response_model=HeartbeatResponse)
32
+ async def heartbeat(payload: HeartbeatRequest) -> HeartbeatResponse:
33
+ now = datetime.now(timezone.utc)
34
+ await device_repo.heartbeat(
35
+ payload.device_id,
36
+ payload.active_restriction_window,
37
+ payload.uptime_seconds,
38
+ )
39
+ await alert_hub.broadcast_admin(
40
+ {
41
+ "type": "device_status",
42
+ "device_id": payload.device_id,
43
+ "message": "Device heartbeat received",
44
+ "active_restriction_window": payload.active_restriction_window,
45
+ "uptime_seconds": payload.uptime_seconds,
46
+ "last_heartbeat_at": now.isoformat(),
47
+ "is_online": True,
48
+ }
49
+ )
50
+ return HeartbeatResponse()
51
+
52
+
53
+ @router.get("")
54
+ async def list_devices() -> list[dict[str, str | bool]]:
55
+ now = datetime.now(timezone.utc)
56
+ online_threshold = now - timedelta(seconds=90)
57
+ rows = await device_repo.list()
58
+ return [
59
+ DeviceStatusRow(
60
+ device_id=row.get("device_id", ""),
61
+ hostname=row.get("hostname", ""),
62
+ agent_version=row.get("agent_version", ""),
63
+ os_version=row.get("os_version", ""),
64
+ last_heartbeat_at=row.get("last_heartbeat_at"),
65
+ active_restriction_window=bool(row.get("active_restriction_window", False)),
66
+ ).model_dump()
67
+ | {
68
+ "is_online": bool(
69
+ isinstance(row.get("last_heartbeat_at"), datetime)
70
+ and row.get("last_heartbeat_at") >= online_threshold
71
+ )
72
+ }
73
+ for row in rows
74
+ ]
app/routers/keys.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from datetime import datetime, timezone
3
+
4
+ from app.models.schemas import KeyGenerateRequest, KeyGenerateResponse, KeyValidateRequest, KeyValidateResponse
5
+ from app.services.alert_hub import alert_hub
6
+ from app.services.key_service import key_service
7
+ from app.core.db import get_db
8
+
9
+ router = APIRouter(prefix="/keys", tags=["keys"])
10
+
11
+
12
+ @router.post("/generate", response_model=KeyGenerateResponse)
13
+ async def generate_key(payload: KeyGenerateRequest) -> KeyGenerateResponse:
14
+ key, expires_at = await key_service.generate(payload.device_id)
15
+ return KeyGenerateResponse(device_id=payload.device_id, key=key, expires_at=expires_at)
16
+
17
+
18
+ @router.post("/validate", response_model=KeyValidateResponse)
19
+ async def validate_key(payload: KeyValidateRequest) -> KeyValidateResponse:
20
+ valid, lock_until, attempts = await key_service.validate(payload.device_id, payload.key)
21
+ if not valid:
22
+ event = {
23
+ "type": "wrong_key",
24
+ "device_id": payload.device_id,
25
+ "attempts": attempts,
26
+ "message": "Wrong key attempt detected. Local UI locked for 5 minutes.",
27
+ "created_at": datetime.now(timezone.utc),
28
+ }
29
+ await get_db().alert_audit.insert_one(event)
30
+ await alert_hub.broadcast_admin(
31
+ event
32
+ )
33
+ return KeyValidateResponse(valid=valid, lock_until=lock_until)
app/routers/policy.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ from app.models.schemas import DevicePolicy, UpdatePolicyRequest
4
+ from app.services.repos import policy_repo
5
+
6
+ router = APIRouter(prefix="/policy", tags=["policy"])
7
+
8
+
9
+ @router.post("/update")
10
+ async def update_policy(payload: UpdatePolicyRequest) -> dict[str, str]:
11
+ await policy_repo.set(payload.device_id, payload.policy.model_dump())
12
+ return {"status": "updated"}
13
+
14
+
15
+ @router.get("/{device_id}", response_model=DevicePolicy)
16
+ async def get_policy(device_id: str) -> DevicePolicy:
17
+ doc = await policy_repo.get(device_id)
18
+ if doc is None:
19
+ return DevicePolicy()
20
+ return DevicePolicy(**doc.get("policy", {}))
app/routers/reviews.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+
3
+ from app.core.auth import AuthUser, get_current_web_user
4
+ from app.models.schemas import GameReviewDecisionRequest, GameReviewDecisionResponse, GameReviewItem
5
+ from app.services.alert_hub import alert_hub
6
+ from app.services.repos import review_repo
7
+
8
+ router = APIRouter(prefix="/reviews", tags=["reviews"])
9
+
10
+
11
+ def _require_admin(user: AuthUser) -> None:
12
+ if user.role != "admin":
13
+ raise HTTPException(status_code=403, detail="Only admin account can review game lock requests")
14
+
15
+
16
+ @router.get("/pending", response_model=list[GameReviewItem])
17
+ async def list_pending_reviews(user: AuthUser = Depends(get_current_web_user)) -> list[GameReviewItem]:
18
+ _require_admin(user)
19
+ rows = await review_repo.list_pending()
20
+ return [
21
+ GameReviewItem(
22
+ review_id=str(row.get("review_id", "")),
23
+ screenshot_id=str(row.get("screenshot_id", "")),
24
+ device_id=str(row.get("device_id", "")),
25
+ timestamp=str(row.get("timestamp", "")),
26
+ status=str(row.get("status", "pending")),
27
+ reason=str(row.get("reason", "")),
28
+ ocr_urls=[str(x) for x in row.get("ocr_urls", [])],
29
+ created_at=row.get("created_at"),
30
+ )
31
+ for row in rows
32
+ ]
33
+
34
+
35
+ @router.post("/decision", response_model=GameReviewDecisionResponse)
36
+ async def decide_review(
37
+ payload: GameReviewDecisionRequest,
38
+ user: AuthUser = Depends(get_current_web_user),
39
+ ) -> GameReviewDecisionResponse:
40
+ _require_admin(user)
41
+ updated = await review_repo.decide(payload.review_id, payload.approve_lock, user.username)
42
+ if updated:
43
+ await alert_hub.broadcast_admin(
44
+ {
45
+ "type": "game_lock_review_decision",
46
+ "device_id": "n/a",
47
+ "message": "Admin submitted lock decision",
48
+ "review_id": payload.review_id,
49
+ "approve_lock": payload.approve_lock,
50
+ }
51
+ )
52
+ return GameReviewDecisionResponse(updated=updated)
app/routers/screenshots.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import uuid
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from fastapi import APIRouter, Depends, File, Form, UploadFile
6
+
7
+ from app.core.auth import AuthUser, get_current_web_user
8
+ from app.core.config import settings
9
+ from app.models.schemas import ScreenshotIngestResponse, ViolationScreenshot
10
+ from app.services.alert_hub import alert_hub
11
+ from app.services.classifier import (
12
+ classify_game_with_ocr_llm,
13
+ classify_sensitive_content,
14
+ )
15
+ from app.services.policy_window import is_restriction_active
16
+ from app.services.repos import (
17
+ audit_repo,
18
+ policy_repo,
19
+ review_repo,
20
+ url_verdict_repo,
21
+ violation_repo,
22
+ )
23
+ from app.services.storage import storage_service
24
+
25
+ router = APIRouter(prefix="/screenshots", tags=["screenshots"])
26
+
27
+
28
+ async def _cleanup_expired_violations() -> None:
29
+ expired = await violation_repo.list_expired()
30
+ for row in expired:
31
+ absolute_path = row.get("absolute_path")
32
+ if isinstance(absolute_path, str):
33
+ storage_service.delete(absolute_path)
34
+ await violation_repo.delete_expired()
35
+
36
+
37
+ @router.post("/ingest", response_model=ScreenshotIngestResponse)
38
+ async def ingest_screenshot(
39
+ device_id: str = Form(...),
40
+ timestamp: str = Form(...),
41
+ suspected_game: bool = Form(False),
42
+ file: UploadFile = File(...),
43
+ ) -> ScreenshotIngestResponse:
44
+ screenshot_id = str(uuid.uuid4())
45
+ _, absolute_path, stored_name = await storage_service.save_upload(file)
46
+
47
+ try:
48
+ moment = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
49
+ except ValueError:
50
+ moment = datetime.now(timezone.utc)
51
+ policy_doc = await policy_repo.get(device_id)
52
+ policy_payload = policy_doc.get("policy", {}) if policy_doc else {}
53
+ windows = policy_payload.get("restriction_windows", [])
54
+ if not is_restriction_active(windows, moment):
55
+ storage_service.delete(absolute_path)
56
+ await audit_repo.log(
57
+ {
58
+ "screenshot_id": screenshot_id,
59
+ "device_id": device_id,
60
+ "timestamp": timestamp,
61
+ "filename": file.filename or "unknown.png",
62
+ "action": "deleted_outside_window",
63
+ "reason": "outside_restriction_window",
64
+ "created_at": datetime.now(timezone.utc),
65
+ }
66
+ )
67
+ return ScreenshotIngestResponse(screenshot_id=screenshot_id)
68
+
69
+ game_task = asyncio.to_thread(
70
+ classify_game_with_ocr_llm,
71
+ absolute_path,
72
+ file.filename or "capture.png",
73
+ suspected_game,
74
+ )
75
+ sensitive_task = asyncio.to_thread(
76
+ classify_sensitive_content,
77
+ absolute_path,
78
+ file.filename or "capture.png",
79
+ )
80
+ game_raw, sensitive_raw = await asyncio.gather(game_task, sensitive_task, return_exceptions=True)
81
+
82
+ if isinstance(game_raw, Exception):
83
+ game_result = {
84
+ "verdict": "not_game",
85
+ "confidence": 0.1,
86
+ "reason": "game-check-error",
87
+ "urls": [],
88
+ "source": "error-fallback",
89
+ }
90
+ else:
91
+ game_result = game_raw
92
+
93
+ if isinstance(sensitive_raw, Exception):
94
+ sensitive_detected, sensitive_confidence, sensitive_reason = (False, 0.0, "sensitive-check-error")
95
+ else:
96
+ sensitive_detected, sensitive_confidence, sensitive_reason = sensitive_raw
97
+
98
+ ocr_urls = [str(x) for x in game_result.get("urls", [])]
99
+
100
+ cached_game_url = await url_verdict_repo.get_cached_game_url(ocr_urls)
101
+ if cached_game_url:
102
+ game_result = {
103
+ **game_result,
104
+ "verdict": "game",
105
+ "confidence": 0.99,
106
+ "reason": f"cached-game-url:{cached_game_url}",
107
+ "source": "url-cache",
108
+ }
109
+
110
+ verdict = str(game_result.get("verdict", "not_game"))
111
+ confidence = float(game_result.get("confidence", 0.1))
112
+ reason = str(game_result.get("reason", "no-game-evidence"))
113
+
114
+ if sensitive_detected:
115
+ image_url = f"{settings.media_prefix}/{stored_name}"
116
+ await violation_repo.insert(
117
+ {
118
+ "screenshot_id": screenshot_id,
119
+ "device_id": device_id,
120
+ "timestamp": timestamp,
121
+ "filename": file.filename or "unknown.png",
122
+ "created_at": datetime.now(timezone.utc).isoformat(),
123
+ "image_url": image_url,
124
+ "confidence": sensitive_confidence,
125
+ "reason": sensitive_reason,
126
+ "absolute_path": absolute_path,
127
+ "visibility_scope": "sensitive_admin",
128
+ "ocr_urls": ocr_urls,
129
+ "expires_at": datetime.now(timezone.utc) + timedelta(hours=24),
130
+ }
131
+ )
132
+ await alert_hub.broadcast_admin(
133
+ {
134
+ "type": "sensitive_detected",
135
+ "device_id": device_id,
136
+ "message": "Sensitive content detected. Routed to sensitive admin scope.",
137
+ "visibility_scope": "sensitive_admin",
138
+ }
139
+ )
140
+ await _cleanup_expired_violations()
141
+ return ScreenshotIngestResponse(screenshot_id=screenshot_id)
142
+
143
+ if verdict == "game":
144
+ await url_verdict_repo.upsert_game_urls(ocr_urls, source=str(game_result.get("source", "llm")))
145
+
146
+ if verdict == "not_game":
147
+ storage_service.delete(absolute_path)
148
+ await audit_repo.log(
149
+ {
150
+ "screenshot_id": screenshot_id,
151
+ "device_id": device_id,
152
+ "timestamp": timestamp,
153
+ "filename": file.filename or "unknown.png",
154
+ "action": "deleted_non_violation",
155
+ "reason": reason,
156
+ "created_at": datetime.now(timezone.utc),
157
+ }
158
+ )
159
+ return ScreenshotIngestResponse(screenshot_id=screenshot_id)
160
+
161
+ image_url = f"{settings.media_prefix}/{stored_name}"
162
+ await violation_repo.insert(
163
+ {
164
+ "screenshot_id": screenshot_id,
165
+ "device_id": device_id,
166
+ "timestamp": timestamp,
167
+ "filename": file.filename or "unknown.png",
168
+ "created_at": datetime.now(timezone.utc).isoformat(),
169
+ "image_url": image_url,
170
+ "confidence": confidence,
171
+ "reason": reason,
172
+ "absolute_path": absolute_path,
173
+ "visibility_scope": "admin",
174
+ "ocr_urls": ocr_urls,
175
+ "expires_at": datetime.now(timezone.utc) + timedelta(hours=24),
176
+ }
177
+ )
178
+
179
+ if verdict == "uncertain":
180
+ review_id = str(uuid.uuid4())
181
+ await review_repo.create_pending(
182
+ {
183
+ "review_id": review_id,
184
+ "screenshot_id": screenshot_id,
185
+ "device_id": device_id,
186
+ "timestamp": timestamp,
187
+ "status": "pending",
188
+ "reason": reason,
189
+ "ocr_urls": ocr_urls,
190
+ "created_at": datetime.now(timezone.utc),
191
+ }
192
+ )
193
+ await alert_hub.broadcast_admin(
194
+ {
195
+ "type": "game_lock_review_required",
196
+ "device_id": device_id,
197
+ "message": "Model uncertain. Admin confirmation is required before lock.",
198
+ "review_id": review_id,
199
+ "screenshot_id": screenshot_id,
200
+ "ocr_urls": ocr_urls,
201
+ }
202
+ )
203
+
204
+ await _cleanup_expired_violations()
205
+ return ScreenshotIngestResponse(screenshot_id=screenshot_id)
206
+
207
+
208
+ @router.get("/violations", response_model=list[ViolationScreenshot])
209
+ async def list_violations(user: AuthUser = Depends(get_current_web_user)) -> list[ViolationScreenshot]:
210
+ await _cleanup_expired_violations()
211
+ rows = await violation_repo.list_active(user.role)
212
+ return [
213
+ ViolationScreenshot(
214
+ screenshot_id=row.get("screenshot_id", ""),
215
+ device_id=row.get("device_id", ""),
216
+ timestamp=row.get("timestamp", ""),
217
+ filename=row.get("filename", ""),
218
+ created_at=row.get("created_at", ""),
219
+ image_url=row.get("image_url", ""),
220
+ confidence=float(row.get("confidence", 0.0)),
221
+ reason=row.get("reason", ""),
222
+ visibility_scope=row.get("visibility_scope", "admin"),
223
+ ocr_urls=[str(x) for x in row.get("ocr_urls", [])],
224
+ )
225
+ for row in rows
226
+ ]
app/services/alert_hub.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import defaultdict
2
+ from typing import Any
3
+
4
+ from fastapi import WebSocket
5
+
6
+
7
+ class AlertHub:
8
+ def __init__(self) -> None:
9
+ self.admin_clients: set[WebSocket] = set()
10
+ self.device_clients: dict[str, set[WebSocket]] = defaultdict(set)
11
+
12
+ async def connect_admin(self, websocket: WebSocket) -> None:
13
+ await websocket.accept()
14
+ self.admin_clients.add(websocket)
15
+
16
+ async def connect_device(self, device_id: str, websocket: WebSocket) -> None:
17
+ await websocket.accept()
18
+ self.device_clients[device_id].add(websocket)
19
+
20
+ def disconnect_admin(self, websocket: WebSocket) -> None:
21
+ self.admin_clients.discard(websocket)
22
+
23
+ def disconnect_device(self, device_id: str, websocket: WebSocket) -> None:
24
+ if device_id in self.device_clients:
25
+ self.device_clients[device_id].discard(websocket)
26
+
27
+ async def broadcast_admin(self, payload: dict[str, Any]) -> None:
28
+ stale: list[WebSocket] = []
29
+ for client in self.admin_clients:
30
+ try:
31
+ await client.send_json(payload)
32
+ except Exception:
33
+ stale.append(client)
34
+ for client in stale:
35
+ self.disconnect_admin(client)
36
+
37
+ async def send_device(self, device_id: str, payload: dict[str, Any]) -> None:
38
+ stale: list[WebSocket] = []
39
+ for client in self.device_clients.get(device_id, set()):
40
+ try:
41
+ await client.send_json(payload)
42
+ except Exception:
43
+ stale.append(client)
44
+ for client in stale:
45
+ self.disconnect_device(device_id, client)
46
+
47
+
48
+ alert_hub = AlertHub()
app/services/classifier.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ from app.core.config import settings
11
+
12
+ MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
13
+ NSFW_CONFIG_PATH = MODELS_DIR / "config.json"
14
+ NSFW_WEIGHTS_PATH = MODELS_DIR / "model.safetensors"
15
+ NSFW_THRESHOLD = 0.75
16
+
17
+ LLM_MODELS = [
18
+ os.getenv("GAME_LLM_MODEL", "Qwen/Qwen2.5-7B-Instruct"),
19
+ "microsoft/Phi-3.5-mini-instruct",
20
+ ]
21
+ LLM_MAX_CHARS = 3000
22
+
23
+ _nsfw_runtime: dict[str, Any] | None = None
24
+ _nsfw_error: str | None = None
25
+ _ocr_reader: Any | None = None
26
+ _ocr_error: str | None = None
27
+
28
+ GAME_KEYWORDS = {
29
+ "valorant",
30
+ "steam",
31
+ "roblox",
32
+ "league",
33
+ "dota",
34
+ "cs2",
35
+ "minecraft",
36
+ "epicgames",
37
+ "riot",
38
+ "crazygames",
39
+ "y8",
40
+ "miniclip",
41
+ }
42
+
43
+ SENSITIVE_KEYWORDS = {
44
+ "porn",
45
+ "sex",
46
+ "xxx",
47
+ "nsfw",
48
+ "adult",
49
+ "nude",
50
+ "erotic",
51
+ }
52
+
53
+ URL_REGEX = re.compile(r"(?:https?://)?(?:www\.)?[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:/[^\s]*)?")
54
+
55
+ GAME_PROMPT = """
56
+ You are a strict classifier for parental-control screenshots.
57
+ Input fields:
58
+ - suspected_game_signal: boolean
59
+ - extracted_urls: list of urls/domains from OCR
60
+ - ocr_text: raw OCR text from screenshot
61
+ Task:
62
+ - Decide if screenshot likely indicates gaming/web-game activity.
63
+ - Return only compact JSON with this schema:
64
+ {
65
+ "verdict": "game" | "not_game" | "uncertain",
66
+ "confidence": 0.0-1.0,
67
+ "reason": "short reason"
68
+ }
69
+ Rules:
70
+ - If clear game domain or game UI terms appear, lean game.
71
+ - If evidence is weak or contradictory, return uncertain.
72
+ - Never output markdown, prose, or extra keys.
73
+ """.strip()
74
+
75
+
76
+ def classify_screenshot(file_path: str, filename: str, suspected_game: bool) -> tuple[bool, float, str]:
77
+ game_result = classify_game_with_ocr_llm(file_path, filename, suspected_game)
78
+ verdict = game_result["verdict"]
79
+ confidence = float(game_result["confidence"])
80
+ reason = str(game_result["reason"])
81
+ if verdict == "game":
82
+ return True, confidence, reason
83
+ if verdict == "uncertain":
84
+ return True, max(confidence, 0.51), "uncertain-review"
85
+ return False, confidence, reason
86
+
87
+
88
+ def classify_game_with_ocr_llm(file_path: str, filename: str, suspected_game: bool) -> dict[str, Any]:
89
+ ocr_text, urls = extract_ocr_text_and_urls(file_path)
90
+ llm = _classify_game_with_llm(ocr_text, urls, suspected_game)
91
+ if llm is not None:
92
+ return {
93
+ "verdict": llm["verdict"],
94
+ "confidence": llm["confidence"],
95
+ "reason": llm["reason"],
96
+ "ocr_text": ocr_text,
97
+ "urls": urls,
98
+ "source": llm.get("source", "llm"),
99
+ }
100
+
101
+ # Fallback heuristic path when model call is unavailable.
102
+ text_lower = ocr_text.lower()
103
+ keyword_hit = any(word in text_lower for word in GAME_KEYWORDS)
104
+ domain_hit = any(_domain_from_url(url) in GAME_KEYWORDS for url in urls)
105
+ if domain_hit or (suspected_game and keyword_hit):
106
+ return {
107
+ "verdict": "game",
108
+ "confidence": 0.78,
109
+ "reason": "ocr-keyword-heuristic",
110
+ "ocr_text": ocr_text,
111
+ "urls": urls,
112
+ "source": "heuristic",
113
+ }
114
+
115
+ if suspected_game:
116
+ return {
117
+ "verdict": "uncertain",
118
+ "confidence": 0.55,
119
+ "reason": "signal-without-clear-ocr",
120
+ "ocr_text": ocr_text,
121
+ "urls": urls,
122
+ "source": "heuristic",
123
+ }
124
+
125
+ return {
126
+ "verdict": "not_game",
127
+ "confidence": 0.2,
128
+ "reason": "no-game-evidence",
129
+ "ocr_text": ocr_text,
130
+ "urls": urls,
131
+ "source": "heuristic",
132
+ }
133
+
134
+
135
+ def extract_ocr_text_and_urls(file_path: str) -> tuple[str, list[str]]:
136
+ reader = _load_ocr_reader()
137
+ if reader is None:
138
+ return "", []
139
+
140
+ try:
141
+ results = reader.readtext(file_path, detail=0, paragraph=True)
142
+ ocr_text = "\n".join(str(x) for x in results).strip()
143
+ except Exception:
144
+ return "", []
145
+
146
+ raw_urls = URL_REGEX.findall(ocr_text)
147
+ normalized = []
148
+ for value in raw_urls:
149
+ item = value.strip().rstrip(".,)")
150
+ if not item:
151
+ continue
152
+ if not item.startswith("http://") and not item.startswith("https://"):
153
+ item = f"https://{item}"
154
+ normalized.append(item.lower())
155
+
156
+ # Keep order stable while deduplicating.
157
+ urls: list[str] = []
158
+ seen: set[str] = set()
159
+ for item in normalized:
160
+ if item in seen:
161
+ continue
162
+ seen.add(item)
163
+ urls.append(item)
164
+
165
+ return ocr_text[:LLM_MAX_CHARS], urls
166
+
167
+
168
+ def _load_ocr_reader() -> Any | None:
169
+ global _ocr_reader
170
+ global _ocr_error
171
+
172
+ if _ocr_reader is not None:
173
+ return _ocr_reader
174
+ if _ocr_error is not None:
175
+ return None
176
+
177
+ try:
178
+ import easyocr
179
+
180
+ _ocr_reader = easyocr.Reader(["en"], gpu=False)
181
+ return _ocr_reader
182
+ except Exception as exc:
183
+ _ocr_error = str(exc)
184
+ return None
185
+
186
+
187
+ def _classify_game_with_llm(ocr_text: str, urls: list[str], suspected_game: bool) -> dict[str, Any] | None:
188
+ if not ocr_text and not urls:
189
+ return None
190
+
191
+ user_payload = {
192
+ "suspected_game_signal": suspected_game,
193
+ "extracted_urls": urls,
194
+ "ocr_text": ocr_text,
195
+ }
196
+
197
+ try:
198
+ from huggingface_hub import InferenceClient
199
+ except Exception:
200
+ return None
201
+
202
+ token = settings.hf_token
203
+ prompt_messages = [
204
+ {"role": "system", "content": GAME_PROMPT},
205
+ {"role": "user", "content": json.dumps(user_payload, ensure_ascii=True)},
206
+ ]
207
+
208
+ for model_name in LLM_MODELS:
209
+ try:
210
+ client = InferenceClient(model=model_name, token=token or None)
211
+ response = client.chat_completion(
212
+ messages=prompt_messages,
213
+ max_tokens=160,
214
+ temperature=0.1,
215
+ top_p=0.9,
216
+ )
217
+ content = ""
218
+ if response.choices:
219
+ content = response.choices[0].message.content or ""
220
+ parsed = _parse_llm_json(content)
221
+ if parsed is None:
222
+ continue
223
+ parsed["source"] = f"llm:{model_name}"
224
+ return parsed
225
+ except Exception:
226
+ continue
227
+
228
+ return None
229
+
230
+
231
+ def _parse_llm_json(content: str) -> dict[str, Any] | None:
232
+ if not content:
233
+ return None
234
+
235
+ text = content.strip()
236
+ try:
237
+ data = json.loads(text)
238
+ except json.JSONDecodeError:
239
+ match = re.search(r"\{.*\}", text, re.DOTALL)
240
+ if not match:
241
+ return None
242
+ try:
243
+ data = json.loads(match.group(0))
244
+ except json.JSONDecodeError:
245
+ return None
246
+
247
+ verdict = str(data.get("verdict", "")).lower()
248
+ if verdict not in {"game", "not_game", "uncertain"}:
249
+ return None
250
+
251
+ confidence_raw = data.get("confidence", 0.5)
252
+ try:
253
+ confidence = float(confidence_raw)
254
+ except (TypeError, ValueError):
255
+ confidence = 0.5
256
+
257
+ confidence = max(0.0, min(1.0, confidence))
258
+ reason = str(data.get("reason", "llm-decision"))[:200]
259
+
260
+ return {"verdict": verdict, "confidence": confidence, "reason": reason}
261
+
262
+
263
+ def _domain_from_url(url: str) -> str:
264
+ parsed = urlparse(url)
265
+ host = parsed.netloc or parsed.path
266
+ host = host.lower().replace("www.", "")
267
+ parts = host.split(".")
268
+ if not parts:
269
+ return host
270
+ return parts[0]
271
+
272
+
273
+ def classify_sensitive_content(file_path: str, filename: str) -> tuple[bool, float, str]:
274
+ # First pass with image model inference; fallback to keyword signal only if unavailable.
275
+ model_result = _classify_sensitive_with_model(file_path)
276
+ if model_result is not None:
277
+ return model_result
278
+
279
+ name_text = f"{Path(file_path).name} {filename}".lower()
280
+ keyword_hit = any(word in name_text for word in SENSITIVE_KEYWORDS)
281
+ if keyword_hit:
282
+ return True, 0.65, "sensitive-keyword-fallback"
283
+ return False, 0.05, "no-sensitive-signal"
284
+
285
+
286
+ def _load_nsfw_runtime() -> dict[str, Any] | None:
287
+ global _nsfw_runtime
288
+ global _nsfw_error
289
+
290
+ if _nsfw_runtime is not None:
291
+ return _nsfw_runtime
292
+ if _nsfw_error is not None:
293
+ return None
294
+
295
+ try:
296
+ import timm
297
+ import torch
298
+ from PIL import Image
299
+ from safetensors.torch import load_file
300
+
301
+ if not NSFW_CONFIG_PATH.exists() or not NSFW_WEIGHTS_PATH.exists():
302
+ _nsfw_error = "missing-local-model-files"
303
+ return None
304
+
305
+ config_data = json.loads(NSFW_CONFIG_PATH.read_text(encoding="utf-8"))
306
+ architecture = str(config_data.get("architecture", "vit_tiny_patch16_384"))
307
+ num_classes = int(config_data.get("num_classes", 2))
308
+ label_names = [str(x).lower() for x in config_data.get("label_names", ["nsfw", "sfw"])]
309
+ pretrained_cfg = config_data.get("pretrained_cfg", {})
310
+
311
+ model = timm.create_model(architecture, pretrained=False, num_classes=num_classes).eval()
312
+ state_dict = load_file(str(NSFW_WEIGHTS_PATH), device="cpu")
313
+ model.load_state_dict(state_dict, strict=False)
314
+
315
+ # Use local config for preprocessing so inference does not depend on remote metadata.
316
+ model.pretrained_cfg = {**getattr(model, "pretrained_cfg", {}), **pretrained_cfg, "label_names": label_names}
317
+
318
+ data_config = timm.data.resolve_model_data_config(model)
319
+ transforms = timm.data.create_transform(**data_config, is_training=False)
320
+
321
+ _nsfw_runtime = {
322
+ "torch": torch,
323
+ "Image": Image,
324
+ "model": model,
325
+ "transforms": transforms,
326
+ "label_names": [str(x).lower() for x in label_names],
327
+ }
328
+ return _nsfw_runtime
329
+ except Exception as exc:
330
+ _nsfw_error = str(exc)
331
+ return None
332
+
333
+
334
+ def _classify_sensitive_with_model(file_path: str) -> tuple[bool, float, str] | None:
335
+ runtime = _load_nsfw_runtime()
336
+ if runtime is None:
337
+ return None
338
+
339
+ torch = runtime["torch"]
340
+ Image = runtime["Image"]
341
+ model = runtime["model"]
342
+ transforms = runtime["transforms"]
343
+ label_names = runtime["label_names"]
344
+
345
+ with Image.open(file_path) as img:
346
+ img = img.convert("RGB")
347
+ with torch.no_grad():
348
+ output = model(transforms(img).unsqueeze(0)).softmax(dim=-1).cpu()[0]
349
+
350
+ scores = [float(x) for x in output.tolist()]
351
+ nsfw_score = _extract_nsfw_score(scores, label_names)
352
+ return (nsfw_score >= NSFW_THRESHOLD, nsfw_score, "timm-marqo-nsfw")
353
+
354
+
355
+ def _extract_nsfw_score(scores: list[float], labels: list[str]) -> float:
356
+ for idx, label in enumerate(labels):
357
+ if "nsfw" in label:
358
+ return scores[idx]
359
+ if len(scores) >= 2:
360
+ return scores[1]
361
+ return scores[0] if scores else 0.0
app/services/key_service.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import secrets
2
+ from hashlib import sha256
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from app.services.repos import key_state_repo
6
+
7
+
8
+ def _hash_key(raw: str) -> str:
9
+ return sha256(raw.encode("utf-8")).hexdigest()
10
+
11
+
12
+ class KeyService:
13
+ def __init__(self, *, ttl_seconds: int = 60, lock_minutes: int = 5) -> None:
14
+ self.ttl_seconds = ttl_seconds
15
+ self.lock_minutes = lock_minutes
16
+
17
+ async def generate(self, device_id: str) -> tuple[str, datetime]:
18
+ key = secrets.token_urlsafe(8)
19
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=self.ttl_seconds)
20
+ await key_state_repo.upsert(
21
+ device_id,
22
+ {
23
+ "key_hash": _hash_key(key),
24
+ "expires_at": expires_at,
25
+ "wrong_attempts": 0,
26
+ "lock_until": None,
27
+ },
28
+ )
29
+ return key, expires_at
30
+
31
+ async def validate(self, device_id: str, candidate: str) -> tuple[bool, datetime | None, int]:
32
+ state = await key_state_repo.get(device_id)
33
+ now = datetime.now(timezone.utc)
34
+
35
+ if state is None:
36
+ lock_until = now + timedelta(minutes=self.lock_minutes)
37
+ await key_state_repo.upsert(
38
+ device_id,
39
+ {
40
+ "wrong_attempts": 1,
41
+ "lock_until": lock_until,
42
+ "expires_at": now,
43
+ "key_hash": "",
44
+ },
45
+ )
46
+ return False, lock_until, 1
47
+
48
+ lock_until_value = state.get("lock_until")
49
+ if isinstance(lock_until_value, datetime) and now < lock_until_value:
50
+ return False, lock_until_value, int(state.get("wrong_attempts", 0))
51
+
52
+ expires_at = state.get("expires_at")
53
+ if not isinstance(expires_at, datetime) or now > expires_at:
54
+ lock_until = now + timedelta(minutes=self.lock_minutes)
55
+ attempts = int(state.get("wrong_attempts", 0)) + 1
56
+ await key_state_repo.upsert(
57
+ device_id,
58
+ {
59
+ "wrong_attempts": attempts,
60
+ "lock_until": lock_until,
61
+ "expires_at": now,
62
+ },
63
+ )
64
+ return False, lock_until, attempts
65
+
66
+ candidate_hash = _hash_key(candidate)
67
+ expected_hash = str(state.get("key_hash", ""))
68
+ if not secrets.compare_digest(candidate_hash, expected_hash):
69
+ attempts = int(state.get("wrong_attempts", 0)) + 1
70
+ lock_until = now + timedelta(minutes=self.lock_minutes)
71
+ await key_state_repo.upsert(
72
+ device_id,
73
+ {
74
+ "wrong_attempts": attempts,
75
+ "lock_until": lock_until,
76
+ },
77
+ )
78
+ return False, lock_until, attempts
79
+
80
+ await key_state_repo.upsert(
81
+ device_id,
82
+ {
83
+ "wrong_attempts": 0,
84
+ "lock_until": None,
85
+ "expires_at": now,
86
+ "key_hash": "",
87
+ },
88
+ )
89
+ return True, None, 0
90
+
91
+
92
+ key_service = KeyService()
app/services/policy_window.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Any
3
+
4
+ _DAY_INDEX = {
5
+ "mon": 0,
6
+ "tue": 1,
7
+ "wed": 2,
8
+ "thu": 3,
9
+ "fri": 4,
10
+ "sat": 5,
11
+ "sun": 6,
12
+ }
13
+
14
+
15
+ def _parse_hhmm(value: str) -> tuple[int, int]:
16
+ hour_text, minute_text = value.split(":", 1)
17
+ return int(hour_text), int(minute_text)
18
+
19
+
20
+ def _minutes_of_day(moment: datetime) -> int:
21
+ return moment.hour * 60 + moment.minute
22
+
23
+
24
+ def is_restriction_active(windows: list[dict[str, Any]], moment: datetime) -> bool:
25
+ if not windows:
26
+ return False
27
+
28
+ weekday = moment.weekday()
29
+ now_minutes = _minutes_of_day(moment)
30
+
31
+ for window in windows:
32
+ day = window.get("day_of_week")
33
+ start = str(window.get("start", "00:00"))
34
+ end = str(window.get("end", "00:00"))
35
+ if day not in _DAY_INDEX:
36
+ continue
37
+
38
+ start_h, start_m = _parse_hhmm(start)
39
+ end_h, end_m = _parse_hhmm(end)
40
+ start_minutes = start_h * 60 + start_m
41
+ end_minutes = end_h * 60 + end_m
42
+ window_day = _DAY_INDEX[day]
43
+
44
+ if start_minutes <= end_minutes:
45
+ if weekday == window_day and start_minutes <= now_minutes <= end_minutes:
46
+ return True
47
+ continue
48
+
49
+ # Overnight window: e.g. 22:00 -> 02:00
50
+ if weekday == window_day and now_minutes >= start_minutes:
51
+ return True
52
+ if weekday == (window_day + 1) % 7 and now_minutes <= end_minutes:
53
+ return True
54
+
55
+ return False
app/services/repos.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+ from typing import Any
3
+
4
+ from app.core.db import get_db
5
+
6
+
7
+ def _clean(doc: dict[str, Any] | None) -> dict[str, Any] | None:
8
+ if doc is None:
9
+ return None
10
+ doc.pop("_id", None)
11
+ return doc
12
+
13
+
14
+ class DeviceRepo:
15
+ async def upsert(self, data: dict[str, Any]) -> None:
16
+ db = get_db()
17
+ await db.devices.update_one(
18
+ {"device_id": data["device_id"]},
19
+ {"$set": {**data, "updated_at": datetime.now(timezone.utc)}},
20
+ upsert=True,
21
+ )
22
+
23
+ async def list(self) -> list[dict[str, Any]]:
24
+ db = get_db()
25
+ rows = await db.devices.find({}, {"_id": 0}).to_list(length=200)
26
+ return rows
27
+
28
+ async def heartbeat(self, device_id: str, active_restriction_window: bool, uptime_seconds: int) -> None:
29
+ db = get_db()
30
+ now = datetime.now(timezone.utc)
31
+ await db.devices.update_one(
32
+ {"device_id": device_id},
33
+ {
34
+ "$set": {
35
+ "device_id": device_id,
36
+ "active_restriction_window": active_restriction_window,
37
+ "uptime_seconds": uptime_seconds,
38
+ "last_heartbeat_at": now,
39
+ "updated_at": now,
40
+ }
41
+ },
42
+ upsert=True,
43
+ )
44
+
45
+
46
+ class PolicyRepo:
47
+ async def set(self, device_id: str, policy: dict[str, Any]) -> None:
48
+ db = get_db()
49
+ await db.policies.update_one(
50
+ {"device_id": device_id},
51
+ {
52
+ "$set": {
53
+ "device_id": device_id,
54
+ "policy": policy,
55
+ "updated_at": datetime.now(timezone.utc),
56
+ }
57
+ },
58
+ upsert=True,
59
+ )
60
+
61
+ async def get(self, device_id: str) -> dict[str, Any] | None:
62
+ db = get_db()
63
+ doc = await db.policies.find_one({"device_id": device_id}, {"_id": 0})
64
+ return _clean(doc)
65
+
66
+
67
+ class ViolationRepo:
68
+ async def insert(self, doc: dict[str, Any]) -> None:
69
+ db = get_db()
70
+ await db.violations.insert_one(doc)
71
+
72
+ async def list_active(self, visibility_scope: str) -> list[dict[str, Any]]:
73
+ db = get_db()
74
+ now = datetime.now(timezone.utc)
75
+ query = {"expires_at": {"$gt": now}, "visibility_scope": visibility_scope}
76
+ rows = (
77
+ await db.violations.find(query, {"_id": 0})
78
+ .sort("timestamp", -1)
79
+ .to_list(length=500)
80
+ )
81
+ return rows
82
+
83
+ async def list_expired(self) -> list[dict[str, Any]]:
84
+ db = get_db()
85
+ now = datetime.now(timezone.utc)
86
+ return await db.violations.find({"expires_at": {"$lte": now}}).to_list(length=500)
87
+
88
+ async def delete_expired(self) -> int:
89
+ db = get_db()
90
+ now = datetime.now(timezone.utc)
91
+ result = await db.violations.delete_many({"expires_at": {"$lte": now}})
92
+ return result.deleted_count
93
+
94
+
95
+ class AuditRepo:
96
+ async def log(self, doc: dict[str, Any]) -> None:
97
+ db = get_db()
98
+ await db.screenshot_audit.insert_one(doc)
99
+
100
+
101
+ class KeyStateRepo:
102
+ async def get(self, device_id: str) -> dict[str, Any] | None:
103
+ db = get_db()
104
+ doc = await db.key_states.find_one({"device_id": device_id})
105
+ return _clean(doc)
106
+
107
+ async def upsert(self, device_id: str, patch: dict[str, Any]) -> None:
108
+ db = get_db()
109
+ await db.key_states.update_one(
110
+ {"device_id": device_id},
111
+ {
112
+ "$set": {
113
+ **patch,
114
+ "device_id": device_id,
115
+ "updated_at": datetime.now(timezone.utc),
116
+ }
117
+ },
118
+ upsert=True,
119
+ )
120
+
121
+
122
+ class UrlVerdictRepo:
123
+ async def get_cached_game_url(self, urls: list[str]) -> str | None:
124
+ if not urls:
125
+ return None
126
+ db = get_db()
127
+ doc = await db.url_verdicts.find_one(
128
+ {"url": {"$in": urls}, "verdict": "game"},
129
+ {"_id": 0, "url": 1},
130
+ )
131
+ if not doc:
132
+ return None
133
+ return str(doc.get("url", ""))
134
+
135
+ async def upsert_game_urls(self, urls: list[str], source: str) -> None:
136
+ if not urls:
137
+ return
138
+ db = get_db()
139
+ now = datetime.now(timezone.utc)
140
+ for url in urls:
141
+ await db.url_verdicts.update_one(
142
+ {"url": url},
143
+ {
144
+ "$set": {
145
+ "url": url,
146
+ "verdict": "game",
147
+ "source": source,
148
+ "updated_at": now,
149
+ }
150
+ },
151
+ upsert=True,
152
+ )
153
+
154
+
155
+ class ReviewRepo:
156
+ async def create_pending(self, doc: dict[str, Any]) -> None:
157
+ db = get_db()
158
+ await db.game_lock_reviews.insert_one(doc)
159
+
160
+ async def list_pending(self, limit: int = 200) -> list[dict[str, Any]]:
161
+ db = get_db()
162
+ rows = (
163
+ await db.game_lock_reviews.find({"status": "pending"}, {"_id": 0})
164
+ .sort("created_at", -1)
165
+ .to_list(length=limit)
166
+ )
167
+ return rows
168
+
169
+ async def decide(self, review_id: str, approve_lock: bool, decided_by: str) -> bool:
170
+ db = get_db()
171
+ result = await db.game_lock_reviews.update_one(
172
+ {"review_id": review_id, "status": "pending"},
173
+ {
174
+ "$set": {
175
+ "status": "approved" if approve_lock else "rejected",
176
+ "decided_by": decided_by,
177
+ "decided_at": datetime.now(timezone.utc),
178
+ }
179
+ },
180
+ )
181
+ return result.modified_count > 0
182
+
183
+
184
+ device_repo = DeviceRepo()
185
+ policy_repo = PolicyRepo()
186
+ violation_repo = ViolationRepo()
187
+ audit_repo = AuditRepo()
188
+ key_state_repo = KeyStateRepo()
189
+ url_verdict_repo = UrlVerdictRepo()
190
+ review_repo = ReviewRepo()
app/services/storage.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from uuid import uuid4
3
+
4
+ from fastapi import UploadFile
5
+
6
+ from app.core.config import settings
7
+
8
+
9
+ class StorageService:
10
+ def __init__(self, base_dir: str) -> None:
11
+ self.base_dir = Path(base_dir)
12
+ self.base_dir.mkdir(parents=True, exist_ok=True)
13
+
14
+ async def save_upload(self, file: UploadFile) -> tuple[str, str, str]:
15
+ extension = Path(file.filename or "capture.png").suffix or ".png"
16
+ image_id = str(uuid4())
17
+ stored_name = f"{image_id}{extension}"
18
+ target_path = self.base_dir / stored_name
19
+
20
+ content = await file.read()
21
+ target_path.write_bytes(content)
22
+ return image_id, str(target_path), stored_name
23
+
24
+ def delete(self, absolute_path: str) -> None:
25
+ target = Path(absolute_path)
26
+ if target.exists():
27
+ target.unlink()
28
+
29
+
30
+ storage_service = StorageService(settings.storage_dir)
models/config.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architecture": "vit_tiny_patch16_384",
3
+ "num_classes": 2,
4
+ "num_features": 192,
5
+ "global_pool": "token",
6
+ "label_names": [
7
+ "NSFW",
8
+ "SFW"
9
+ ],
10
+ "pretrained_cfg": {
11
+ "tag": "augreg_in21k_ft_in1k",
12
+ "custom_load": true,
13
+ "input_size": [
14
+ 3,
15
+ 384,
16
+ 384
17
+ ],
18
+ "fixed_input_size": true,
19
+ "interpolation": "bicubic",
20
+ "crop_pct": 1.0,
21
+ "crop_mode": "center",
22
+ "mean": [
23
+ 0.5,
24
+ 0.5,
25
+ 0.5
26
+ ],
27
+ "std": [
28
+ 0.5,
29
+ 0.5,
30
+ 0.5
31
+ ],
32
+ "num_classes": 1000,
33
+ "pool_size": null,
34
+ "first_conv": "patch_embed.proj",
35
+ "classifier": "head"
36
+ }
37
+ }
models/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6bf2e0f64a1d20169736c2836e3a787b12379fdc08ba87f7d94a7a3d58eeefce
3
+ size 22404720
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ pydantic==2.9.2
4
+ pydantic-settings==2.1.0
5
+ python-multipart==0.0.12
6
+ motor==3.6.0
7
+ pymongo==4.9.1
8
+ python-dotenv==1.0.1
9
+ Pillow==10.4.0
10
+ timm==1.0.9
11
+ torch==2.4.1
12
+ safetensors==0.4.5
13
+ easyocr==1.7.1
14
+ huggingface_hub==0.24.7