Rthur2003 commited on
Commit
d411bd0
·
1 Parent(s): b65bdc6

feat: crown Commend API routes remix tools data processing ve istemci IP utilities eklendi

Browse files
.gitignore CHANGED
@@ -110,3 +110,6 @@ client_secret*.json
110
  token.json
111
  generate_youtube_token.py
112
  commend_history.json
 
 
 
 
110
  token.json
111
  generate_youtube_token.py
112
  commend_history.json
113
+
114
+ # Design-tool cache
115
+ .impeccable/
app/routes/commend/router.py CHANGED
@@ -5,6 +5,7 @@ AI-powered YouTube comment generation and posting.
5
 
6
  from __future__ import annotations
7
 
 
8
  import os
9
  import time
10
  from collections import defaultdict
@@ -23,6 +24,7 @@ from .youtube_service import (
23
  is_youtube_configured,
24
  is_oauth_configured
25
  )
 
26
  from .gemini_service import generate_comment
27
  from .comment_tracker import (
28
  is_video_commented,
@@ -154,13 +156,20 @@ def _check_rate_limit(client_ip: str) -> None:
154
  _rate_limit_store[client_ip].append(now)
155
 
156
 
 
 
 
 
 
 
 
157
  async def _require_api_key(
158
  request: Request,
159
  x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
160
  ) -> None:
161
  """Validate API key. Fail-closed: if COMMEND_REQUIRE_AUTH is true (default)
162
  and no COMMEND_API_KEY is configured, all requests are rejected."""
163
- client_ip = request.client.host if request.client else "unknown"
164
 
165
  # Always apply rate-limit regardless of auth configuration
166
  _check_rate_limit(client_ip)
@@ -175,7 +184,7 @@ async def _require_api_key(
175
  )
176
  # Explicitly opted out of auth (COMMEND_REQUIRE_AUTH=false)
177
  return
178
- if x_api_key != _COMMEND_API_KEY:
179
  logger.warning(f"Unauthorized commend request from {client_ip}")
180
  raise HTTPException(
181
  status_code=status.HTTP_401_UNAUTHORIZED,
@@ -183,6 +192,25 @@ async def _require_api_key(
183
  )
184
 
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  # --- API Endpoints ---
187
 
188
  @router.get("/health", response_model=CommendHealthResponse)
@@ -203,7 +231,7 @@ async def commend_health_check():
203
  )
204
 
205
 
206
- @router.post("/video-details", response_model=VideoDetailsResponse)
207
  async def get_video_info(request: GenerateCommentRequest):
208
  """
209
  Fetch video details without generating a comment.
@@ -288,7 +316,7 @@ async def generate_youtube_comment(request: GenerateCommentRequest):
288
  )
289
 
290
 
291
- @router.post("/post", response_model=PostCommentResponse, dependencies=[Depends(_require_api_key)])
292
  async def post_comment_to_youtube(request: PostCommentRequest):
293
  """
294
  Post a comment to YouTube.
@@ -328,7 +356,7 @@ async def post_comment_to_youtube(request: PostCommentRequest):
328
  logger.error(f"Comment posting failed: {error}")
329
  raise HTTPException(
330
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
331
- detail={"code": "posting_failed", "message": str(error)}
332
  )
333
 
334
  # Mark video as commented (for duplicate prevention)
 
5
 
6
  from __future__ import annotations
7
 
8
+ import hmac
9
  import os
10
  import time
11
  from collections import defaultdict
 
24
  is_youtube_configured,
25
  is_oauth_configured
26
  )
27
+ from ...services.client_ip import client_ip as _client_ip
28
  from .gemini_service import generate_comment
29
  from .comment_tracker import (
30
  is_video_commented,
 
156
  _rate_limit_store[client_ip].append(now)
157
 
158
 
159
+ def _key_matches(candidate: Optional[str]) -> bool:
160
+ """Constant-time comparison, so response timing says nothing about the key."""
161
+ if not _COMMEND_API_KEY or candidate is None:
162
+ return False
163
+ return hmac.compare_digest(candidate.encode("utf-8"), _COMMEND_API_KEY.encode("utf-8"))
164
+
165
+
166
  async def _require_api_key(
167
  request: Request,
168
  x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
169
  ) -> None:
170
  """Validate API key. Fail-closed: if COMMEND_REQUIRE_AUTH is true (default)
171
  and no COMMEND_API_KEY is configured, all requests are rejected."""
172
+ client_ip = _client_ip(request)
173
 
174
  # Always apply rate-limit regardless of auth configuration
175
  _check_rate_limit(client_ip)
 
184
  )
185
  # Explicitly opted out of auth (COMMEND_REQUIRE_AUTH=false)
186
  return
187
+ if not _key_matches(x_api_key):
188
  logger.warning(f"Unauthorized commend request from {client_ip}")
189
  raise HTTPException(
190
  status_code=status.HTTP_401_UNAUTHORIZED,
 
192
  )
193
 
194
 
195
+ async def _rate_limit_only(request: Request) -> None:
196
+ """For read endpoints that spend YouTube quota but need no key."""
197
+ _check_rate_limit(_client_ip(request))
198
+
199
+
200
+ async def _require_posting_key(
201
+ request: Request,
202
+ x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
203
+ ) -> None:
204
+ """Posting writes to a YouTube account that belongs to the server, so it needs
205
+ the key even when COMMEND_REQUIRE_AUTH=false opened the other endpoints."""
206
+ await _require_api_key(request, x_api_key)
207
+ if not _COMMEND_API_KEY or not _key_matches(x_api_key):
208
+ raise HTTPException(
209
+ status_code=status.HTTP_401_UNAUTHORIZED,
210
+ detail={"code": "unauthorized", "message": "Invalid or missing API key."}
211
+ )
212
+
213
+
214
  # --- API Endpoints ---
215
 
216
  @router.get("/health", response_model=CommendHealthResponse)
 
231
  )
232
 
233
 
234
+ @router.post("/video-details", response_model=VideoDetailsResponse, dependencies=[Depends(_rate_limit_only)])
235
  async def get_video_info(request: GenerateCommentRequest):
236
  """
237
  Fetch video details without generating a comment.
 
316
  )
317
 
318
 
319
+ @router.post("/post", response_model=PostCommentResponse, dependencies=[Depends(_require_posting_key)])
320
  async def post_comment_to_youtube(request: PostCommentRequest):
321
  """
322
  Post a comment to YouTube.
 
356
  logger.error(f"Comment posting failed: {error}")
357
  raise HTTPException(
358
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
359
+ detail={"code": "posting_failed", "message": "The comment could not be posted."}
360
  )
361
 
362
  # Mark video as commented (for duplicate prevention)
app/routes/data_processing.py CHANGED
@@ -14,6 +14,7 @@ import logging
14
  from app.schemas import AudioAugmentationOptions, AudioConvertOptions, DatasetEntryMetadata
15
  from app.services.audio_processor import process_audio, convert_audio_format
16
  from app.services.dataset_organizer import analyze_for_organization
 
17
 
18
  router = APIRouter(prefix="/api/process", tags=["Data Processing"])
19
  logger = logging.getLogger(__name__)
@@ -26,7 +27,7 @@ _PROCESS_MAX_IPS = 10_000
26
 
27
 
28
  async def _process_rate_limit(request: Request) -> None:
29
- client_ip = request.client.host if request.client else "unknown"
30
  now = time.time()
31
  cutoff = now - _PROCESS_RATE_WINDOW
32
 
 
14
  from app.schemas import AudioAugmentationOptions, AudioConvertOptions, DatasetEntryMetadata
15
  from app.services.audio_processor import process_audio, convert_audio_format
16
  from app.services.dataset_organizer import analyze_for_organization
17
+ from app.services.client_ip import client_ip as _client_ip
18
 
19
  router = APIRouter(prefix="/api/process", tags=["Data Processing"])
20
  logger = logging.getLogger(__name__)
 
27
 
28
 
29
  async def _process_rate_limit(request: Request) -> None:
30
+ client_ip = _client_ip(request)
31
  now = time.time()
32
  cutoff = now - _PROCESS_RATE_WINDOW
33
 
app/routes/remix.py CHANGED
@@ -14,6 +14,7 @@ from pydantic import ValidationError
14
  from app.schemas import MultitrackAnalysis, MultitrackOptions, RemixAnalysis, RemixOptions
15
  from app.services.multitrack_mixer import MAX_CHANNELS, MIN_CHANNELS, mix_tracks
16
  from app.services.remix_engine import build_remix
 
17
 
18
  router = APIRouter(prefix="/api/remix", tags=["Creator Studio"])
19
  logger = logging.getLogger(__name__)
@@ -28,7 +29,7 @@ _REMIX_MAX_IPS = 10_000
28
 
29
 
30
  async def _remix_rate_limit(request: Request) -> None:
31
- client_ip = request.client.host if request.client else "unknown"
32
  now = time.time()
33
  cutoff = now - _REMIX_RATE_WINDOW
34
 
 
14
  from app.schemas import MultitrackAnalysis, MultitrackOptions, RemixAnalysis, RemixOptions
15
  from app.services.multitrack_mixer import MAX_CHANNELS, MIN_CHANNELS, mix_tracks
16
  from app.services.remix_engine import build_remix
17
+ from app.services.client_ip import client_ip as _client_ip
18
 
19
  router = APIRouter(prefix="/api/remix", tags=["Creator Studio"])
20
  logger = logging.getLogger(__name__)
 
29
 
30
 
31
  async def _remix_rate_limit(request: Request) -> None:
32
+ client_ip = _client_ip(request)
33
  now = time.time()
34
  cutoff = now - _REMIX_RATE_WINDOW
35
 
app/services/client_ip.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The visitor's address behind the Hugging Face proxy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import Request
6
+
7
+
8
+ def client_ip(request: Request) -> str:
9
+ """The address rate limits are counted against.
10
+
11
+ uvicorn sees the proxy, so request.client alone would put every visitor in
12
+ one bucket; the proxy lists the original client first in X-Forwarded-For.
13
+ """
14
+ forwarded = request.headers.get("x-forwarded-for", "")
15
+ first = forwarded.split(",")[0].strip()
16
+ if first:
17
+ return first
18
+ return request.client.host if request.client else "unknown"
tests/test_commend_auth.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Crown Commend: who may post, and how requests are counted.
2
+
3
+ Runs on a small app holding only the Commend router, with YouTube stubbed.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import importlib
9
+ from collections import defaultdict
10
+
11
+ import pytest
12
+ from fastapi import FastAPI
13
+ from fastapi.testclient import TestClient
14
+
15
+ from app.routes.commend.router import router
16
+
17
+ commend_module = importlib.import_module("app.routes.commend.router")
18
+
19
+ URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
20
+ POST_BODY = {"videoUrl": URL, "commentText": "Nice one."}
21
+
22
+
23
+ @pytest.fixture
24
+ def client(monkeypatch):
25
+ monkeypatch.setattr(commend_module, "_rate_limit_store", defaultdict(list))
26
+ monkeypatch.setattr(commend_module, "get_video_details", lambda url: (None, "stubbed"))
27
+ app = FastAPI()
28
+ app.include_router(router)
29
+ return TestClient(app)
30
+
31
+
32
+ def configure(monkeypatch, *, key=None, require_auth=True, posting=True):
33
+ monkeypatch.setattr(commend_module, "_COMMEND_API_KEY", key)
34
+ monkeypatch.setattr(commend_module, "_COMMEND_REQUIRE_AUTH", require_auth)
35
+ monkeypatch.setattr(commend_module, "_COMMEND_POSTING_ENABLED", posting)
36
+
37
+
38
+ def test_posting_needs_a_key_even_when_auth_is_switched_off(client, monkeypatch) -> None:
39
+ configure(monkeypatch, key=None, require_auth=False, posting=True)
40
+ response = client.post("/api/commend/post", json=POST_BODY)
41
+ assert response.status_code == 401
42
+ assert response.json()["detail"]["code"] == "unauthorized"
43
+
44
+
45
+ def test_posting_rejects_a_wrong_key(client, monkeypatch) -> None:
46
+ configure(monkeypatch, key="secret-key", require_auth=False)
47
+ response = client.post("/api/commend/post", json=POST_BODY, headers={"X-API-Key": "nope"})
48
+ assert response.status_code == 401
49
+
50
+
51
+ def test_posting_with_the_key_reaches_the_posting_switch(client, monkeypatch) -> None:
52
+ configure(monkeypatch, key="secret-key", posting=False)
53
+ response = client.post("/api/commend/post", json=POST_BODY, headers={"X-API-Key": "secret-key"})
54
+ assert response.status_code == 403
55
+ assert response.json()["detail"]["code"] == "posting_disabled"
56
+
57
+
58
+ def test_posting_error_does_not_echo_the_provider_message(client, monkeypatch) -> None:
59
+ configure(monkeypatch, key="secret-key", posting=True)
60
+ monkeypatch.setattr(commend_module, "is_video_commented", lambda video_id: False)
61
+ monkeypatch.setattr(commend_module, "post_youtube_comment", lambda video_id, text: (None, "token=abc123 expired"))
62
+ response = client.post("/api/commend/post", json=POST_BODY, headers={"X-API-Key": "secret-key"})
63
+ assert response.status_code == 500
64
+ assert "abc123" not in response.text
65
+
66
+
67
+ def test_generate_stays_closed_when_auth_is_required_but_no_key_is_set(client, monkeypatch) -> None:
68
+ configure(monkeypatch, key=None, require_auth=True)
69
+ body = {"videoUrl": URL, "language": "English", "commentStyle": "supportive"}
70
+ assert client.post("/api/commend/generate", json=body).status_code == 503
71
+
72
+
73
+ def test_video_details_is_rate_limited(client, monkeypatch) -> None:
74
+ monkeypatch.setattr(commend_module, "_RATE_LIMIT_MAX", 2)
75
+ body = {"videoUrl": URL, "language": "English", "commentStyle": "supportive"}
76
+ codes = [client.post("/api/commend/video-details", json=body).status_code for _ in range(3)]
77
+ assert codes[:2] == [400, 400]
78
+ assert codes[2] == 429
79
+
80
+
81
+ def test_visitors_behind_the_proxy_are_counted_separately(client, monkeypatch) -> None:
82
+ monkeypatch.setattr(commend_module, "_RATE_LIMIT_MAX", 1)
83
+ body = {"videoUrl": URL, "language": "English", "commentStyle": "supportive"}
84
+ first = client.post("/api/commend/video-details", json=body, headers={"X-Forwarded-For": "203.0.113.1"})
85
+ second = client.post("/api/commend/video-details", json=body, headers={"X-Forwarded-For": "203.0.113.2"})
86
+ again = client.post("/api/commend/video-details", json=body, headers={"X-Forwarded-For": "203.0.113.1"})
87
+ assert first.status_code == 400
88
+ assert second.status_code == 400
89
+ assert again.status_code == 429