sync: 141 file da Baida98/AI@32e8386b (2026-08-06 20:21 UTC) [deploy-all]

#2
by Baida-A - opened
Files changed (3) hide show
  1. README.md +2 -1
  2. api/search.py +364 -9
  3. api/web.py +40 -1
README.md CHANGED
@@ -19,7 +19,8 @@ Backend Python FastAPI per Agente AI. Streaming LLM, memoria, esecuzione codice,
19
  - `POST /api/reason/loop` — agent reasoning loop
20
  - `POST /api/exec` — esecuzione codice Python
21
  - `POST /api/execute-shell` — shell commands
22
- - `POST /api/analyze-image` analisi immagine via Vision AI
 
23
  - `WS /ws/terminal` — PTY WebSocket terminal
24
 
25
  ## Stack
 
19
  - `POST /api/reason/loop` — agent reasoning loop
20
  - `POST /api/exec` — esecuzione codice Python
21
  - `POST /api/execute-shell` — shell commands
22
+ - `POST /api/search` web search proxy
23
+ - `POST /api/fetch-page` — page fetch proxy
24
  - `WS /ws/terminal` — PTY WebSocket terminal
25
 
26
  ## Stack
api/search.py CHANGED
@@ -1,8 +1,12 @@
1
- """backend/api/search.py — Image analysis routes.
2
- Rimosse funzionalità di ricerca e fetch proxy per conformità alle policy Hugging Face (Reverse Proxy).
 
 
 
3
  """
4
- import os, asyncio, json
5
  import re as _re
 
6
  from typing import Optional
7
  from fastapi import APIRouter, Depends, HTTPException, Request
8
  from pydantic import BaseModel
@@ -13,20 +17,362 @@ _logger = logging.getLogger("api.search")
13
 
14
  router = APIRouter()
15
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  class AnalyzeImageRequest(BaseModel):
17
  dataUrl: str
18
  filename: Optional[str] = "image"
19
  question: Optional[str] = None
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  # ── Analyze image ──────────────────────────────────────────────────────────────
22
 
23
  @router.post('/api/analyze-image')
24
  async def analyze_image(
25
  body: AnalyzeImageRequest, request: Request,
26
- role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
27
  ):
28
  """
29
  Vision AI — OpenRouter free VL models → Gemini fallback.
 
30
  [S-GAP23] X-Internal-Token guard — protegge consumi LLM da abusi esterni.
31
  """
32
  import re as _re2, json as _json
@@ -78,6 +424,8 @@ async def analyze_image(
78
  }]
79
 
80
  last_err = None
 
 
81
  for (pname, api_key, base_url, model) in VISION_MATRIX:
82
  try:
83
  vclient = _OAI(api_key=api_key, base_url=base_url)
@@ -99,10 +447,17 @@ async def analyze_image(
99
  'provider': f'{pname}/{model}',
100
  'filename': body.filename,
101
  }
102
- except _json.JSONDecodeError:
103
- continue
104
- except Exception as e:
105
- last_err = str(e)
 
 
 
 
 
 
 
106
  continue
107
 
108
- return {'ok': False, 'error': last_err or 'Analisi fallita su tutti i provider.'}
 
1
+ """backend/api/search.py — Search, fetch-page, analyze-image proxy routes (S358).
2
+
3
+ S358: proxy_search web branch ora usa asyncio.gather per eseguire Wikipedia, DDG HTML,
4
+ SearXNG e DDG Instant Answer in parallelo invece di in cascata. Worst case: ~8s → ~8s
5
+ (stesso bound sul provider più lento), ma caso medio: ~3s invece di ~20s.
6
  """
7
+ import os, asyncio, html, json
8
  import re as _re
9
+ import urllib.request, urllib.parse
10
  from typing import Optional
11
  from fastapi import APIRouter, Depends, HTTPException, Request
12
  from pydantic import BaseModel
 
17
 
18
  router = APIRouter()
19
 
20
+
21
+ class SearchRequest(BaseModel):
22
+ query: str
23
+ type: str = "web"
24
+ limit: int = 10
25
+ lang: Optional[str] = None
26
+
27
+
28
+ class FetchPageRequest(BaseModel):
29
+ url: str
30
+
31
+
32
  class AnalyzeImageRequest(BaseModel):
33
  dataUrl: str
34
  filename: Optional[str] = "image"
35
  question: Optional[str] = None
36
 
37
+
38
+ # ── Helpers ────────────────────────────────────────────────────────────────────
39
+
40
+ def _clean_html(raw: str, max_chars: int = 20_000) -> tuple[str, list[str]]:
41
+ code_blocks: list[str] = []
42
+ for m in _re.findall(r'<(?:pre|code)[^>]*>(.*?)</(?:pre|code)>', raw, _re.S | _re.I):
43
+ cleaned = _re.sub(r'<[^>]+>', '', m)
44
+ decoded = html.unescape(cleaned).strip()
45
+ if decoded:
46
+ code_blocks.append(decoded[:2000])
47
+ raw = _re.sub(r'<(script|style)[^>]*>.*?</\1>', '', raw, flags=_re.S | _re.I)
48
+ text = _re.sub(r'<[^>]+>', ' ', raw)
49
+ text = _re.sub(r'\s+', ' ', html.unescape(text)).strip()
50
+ return text[:max_chars], code_blocks[:20]
51
+
52
+
53
+ def _get_title(raw: str) -> str:
54
+ m = _re.search(r'<title[^>]*>(.*?)</title>', raw, _re.S | _re.I)
55
+ if m:
56
+ return html.unescape(_re.sub(r'<[^>]+>', '', m.group(1))).strip()[:200] # S580: 120→200
57
+ return ""
58
+
59
+
60
+ # ── Web search coroutines (S358: run in parallel via asyncio.gather) ───────────
61
+
62
+ async def _wiki_search(q: str, limit: int) -> list[dict]:
63
+ try:
64
+ wiki_qs = urllib.parse.urlencode({
65
+ 'action': 'query', 'list': 'search', 'srsearch': q,
66
+ 'format': 'json', 'utf8': '1', 'srlimit': min(limit, 5), 'srnamespace': '0',
67
+ })
68
+ wiki_req = urllib.request.Request(
69
+ f'https://en.wikipedia.org/w/api.php?{wiki_qs}',
70
+ headers={'User-Agent': 'agente-ai/3.1'},
71
+ )
72
+ wiki_data = await asyncio.to_thread(
73
+ lambda: json.loads(urllib.request.urlopen(wiki_req, timeout=6).read())
74
+ )
75
+ results = []
76
+ for item in wiki_data.get('query', {}).get('search', [])[:limit]:
77
+ snippet = _re.sub(r'<[^>]+>', '', item.get('snippet', ''))
78
+ results.append({
79
+ 'title': item.get('title', ''),
80
+ 'url': 'https://en.wikipedia.org/wiki/' + item.get('title', '').replace(' ', '_'),
81
+ # S598: snippet 300→500 — snippet Wikipedia troncati (spesso > 300 chars)
82
+ 'snippet': html.unescape(snippet)[:500],
83
+ 'source': 'web',
84
+ })
85
+ return results
86
+ except Exception:
87
+ return []
88
+
89
+
90
+ async def _ddg_html_search(q: str, limit: int) -> list[dict]:
91
+ try:
92
+ ddg_qs = urllib.parse.urlencode({'q': q, 'kl': 'it-it'})
93
+ ddg_req = urllib.request.Request(
94
+ f'https://html.duckduckgo.com/html/?{ddg_qs}',
95
+ headers={
96
+ 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1',
97
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
98
+ 'Accept-Language': 'it-IT,it;q=0.9,en;q=0.8',
99
+ },
100
+ )
101
+ raw_ddg = await asyncio.to_thread(
102
+ lambda: urllib.request.urlopen(ddg_req, timeout=10).read().decode('utf-8', errors='replace')
103
+ )
104
+ links = _re.findall(r'class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>', raw_ddg, _re.S)
105
+ snippets_raw = _re.findall(r'class="result__snippet"[^>]*>(.*?)</a>', raw_ddg, _re.S)
106
+ results = []
107
+ for i, (href, title_html) in enumerate(links[:limit]):
108
+ title = html.unescape(_re.sub(r'<[^>]+>', '', title_html)).strip()
109
+ snippet = html.unescape(_re.sub(r'<[^>]+>', '', snippets_raw[i] if i < len(snippets_raw) else '')).strip()
110
+ if title and href.startswith('http'):
111
+ # S598: snippet 300→500 — snippet DDG HTML troncati
112
+ results.append({'title': title, 'url': href, 'snippet': snippet[:500], 'source': 'web'})
113
+ return results
114
+ except Exception:
115
+ return []
116
+
117
+
118
+ async def _searxng_search(q: str, limit: int) -> list[dict]:
119
+ try:
120
+ sx_qs = urllib.parse.urlencode({'q': q, 'format': 'json', 'language': 'en', 'safesearch': '0', 'categories': 'general'})
121
+ sx_req = urllib.request.Request(
122
+ f'https://searx.be/search?{sx_qs}',
123
+ headers={'User-Agent': 'agente-ai/3.1', 'Accept': 'application/json'},
124
+ )
125
+ sx_data = await asyncio.to_thread(
126
+ lambda: json.loads(urllib.request.urlopen(sx_req, timeout=8).read())
127
+ )
128
+ return [
129
+ {
130
+ 'title': item.get('title', '')[:200], # S580: 100→200
131
+ 'url': item.get('url', ''),
132
+ # S598: snippet 300→500 — SearXNG content troncato
133
+ 'snippet': item.get('content', '')[:500],
134
+ 'source': 'web',
135
+ }
136
+ for item in sx_data.get('results', [])[:limit]
137
+ ]
138
+ except Exception:
139
+ return []
140
+
141
+
142
+ async def _brave_search(q: str, limit: int) -> list[dict]:
143
+ """S601: Brave Search API — attivo solo se BRAVE_SEARCH_API_KEY impostata."""
144
+ api_key = os.environ.get("BRAVE_SEARCH_API_KEY", "")
145
+ if not api_key:
146
+ return []
147
+ try:
148
+ import httpx as _httpx_b
149
+ qs = urllib.parse.urlencode({"q": q, "count": min(limit, 20), "search_lang": "it"})
150
+ async with _httpx_b.AsyncClient(timeout=8.0) as c:
151
+ r = await c.get(
152
+ f"https://api.search.brave.com/res/v1/web/search?{qs}",
153
+ headers={
154
+ "Accept": "application/json",
155
+ "Accept-Encoding": "gzip",
156
+ "X-Subscription-Token": api_key,
157
+ },
158
+ )
159
+ data = r.json()
160
+ return [
161
+ {
162
+ "title": item.get("title", "")[:200],
163
+ "url": item.get("url", ""),
164
+ "snippet": item.get("description", "")[:500],
165
+ "source": "web",
166
+ }
167
+ for item in data.get("web", {}).get("results", [])[:limit]
168
+ if item.get("url")
169
+ ]
170
+ except Exception:
171
+ return []
172
+
173
+
174
+ async def _tavily_search(q: str, limit: int) -> list[dict]:
175
+ """S601: Tavily Search API — attivo solo se TAVILY_API_KEY impostata."""
176
+ api_key = os.environ.get("TAVILY_API_KEY", "")
177
+ if not api_key:
178
+ return []
179
+ try:
180
+ import httpx as _httpx_t
181
+ async with _httpx_t.AsyncClient(timeout=10.0) as c:
182
+ r = await c.post(
183
+ "https://api.tavily.com/search",
184
+ json={
185
+ "api_key": api_key,
186
+ "query": q,
187
+ "max_results": min(limit, 10),
188
+ "search_depth": "basic",
189
+ },
190
+ headers={"Content-Type": "application/json"},
191
+ )
192
+ data = r.json()
193
+ return [
194
+ {
195
+ "title": item.get("title", "")[:200],
196
+ "url": item.get("url", ""),
197
+ "snippet": item.get("content", "")[:500],
198
+ "source": "web",
199
+ }
200
+ for item in data.get("results", [])[:limit]
201
+ if item.get("url")
202
+ ]
203
+ except Exception:
204
+ return []
205
+
206
+
207
+ async def _ddg_instant_search(q: str, limit: int) -> list[dict]:
208
+ try:
209
+ qs = urllib.parse.urlencode({'q': q, 'format': 'json', 'no_html': '1', 'no_redirect': '1', 'skip_disambig': '1'})
210
+ req = urllib.request.Request(
211
+ f'https://api.duckduckgo.com/?{qs}',
212
+ headers={'User-Agent': 'agente-ai/3.1'},
213
+ )
214
+ data = await asyncio.to_thread(
215
+ lambda: json.loads(urllib.request.urlopen(req, timeout=8).read())
216
+ )
217
+ results = []
218
+ if data.get('AbstractText'):
219
+ results.append({
220
+ 'title': data.get('Heading', q),
221
+ 'url': data.get('AbstractURL', ''),
222
+ # S598: snippet 300→500 — DDG instant AbstractText troncato
223
+ 'snippet': data['AbstractText'][:500],
224
+ 'source': 'web',
225
+ })
226
+ for topic in data.get('RelatedTopics', [])[:limit]:
227
+ if isinstance(topic, dict) and topic.get('Text'):
228
+ results.append({
229
+ 'title': topic.get('Text', '')[:200], # S580: 80→200
230
+ 'url': topic.get('FirstURL', ''),
231
+ 'snippet': topic.get('Text', '')[:300], # S580: 200→300
232
+ 'source': 'web',
233
+ })
234
+ return results
235
+ except Exception:
236
+ return []
237
+
238
+
239
+ # ── Search ─────────────────────────────────────────────────────────────────────
240
+
241
+ @router.post('/api/search')
242
+ async def proxy_search(req: SearchRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
243
+ q = req.query.strip()[:200]
244
+ if not q:
245
+ return {'results': []}
246
+
247
+ results = []
248
+
249
+ try:
250
+ if req.type == "github_repo":
251
+ qs = urllib.parse.urlencode({'q': q + (f' language:{req.lang}' if req.lang else ''), 'per_page': min(req.limit, 10)})
252
+ url = f'https://api.github.com/search/repositories?{qs}'
253
+ gh_req = urllib.request.Request(url, headers={'User-Agent': 'agente-ai/3.1', 'Accept': 'application/vnd.github+json'})
254
+ gh_token = os.getenv('GITHUB_TOKEN', '')
255
+ if gh_token:
256
+ gh_req.add_header('Authorization', f'Bearer {gh_token}')
257
+ def _do_gh():
258
+ with urllib.request.urlopen(gh_req, timeout=8) as r:
259
+ return json.loads(r.read())
260
+ data = await asyncio.to_thread(_do_gh)
261
+ for item in data.get('items', [])[:req.limit]:
262
+ results.append({
263
+ 'title': item.get('full_name', ''),
264
+ 'url': item.get('html_url', ''),
265
+ 'snippet': (item.get('description') or '')[:300], # S584: 200→300
266
+ 'source': 'github',
267
+ })
268
+
269
+ elif req.type == "npm":
270
+ qs = urllib.parse.urlencode({'text': q, 'size': min(req.limit, 20)})
271
+ url = f'https://registry.npmjs.org/-/v1/search?{qs}'
272
+ _npm_r = urllib.request.Request(url, headers={'User-Agent': 'agente-ai/3.1'})
273
+ data = await asyncio.to_thread(lambda: json.loads(urllib.request.urlopen(_npm_r, timeout=8).read()))
274
+ for obj in data.get('objects', [])[:req.limit]:
275
+ pkg = obj.get('package', {})
276
+ results.append({
277
+ 'title': pkg.get('name', ''),
278
+ 'url': f"https://www.npmjs.com/package/{pkg.get('name', '')}",
279
+ 'snippet': pkg.get('description', '')[:300], # S584: 200→300
280
+ 'source': 'docs',
281
+ })
282
+
283
+ elif req.type == "pypi":
284
+ encoded = urllib.parse.quote(q)
285
+ url = f'https://pypi.org/pypi/{encoded}/json'
286
+ try:
287
+ _pypi_r = urllib.request.Request(url, headers={'User-Agent': 'agente-ai/3.1'})
288
+ data = await asyncio.to_thread(lambda: json.loads(urllib.request.urlopen(_pypi_r, timeout=8).read()))
289
+ info = data.get('info', {})
290
+ results.append({
291
+ 'title': info.get('name', q),
292
+ 'url': info.get('project_url') or f'https://pypi.org/project/{q}/',
293
+ 'snippet': (info.get('summary') or '')[:300], # S584: 200→300
294
+ 'source': 'docs',
295
+ })
296
+ except Exception as _exc:
297
+ _logger.debug("[search] silenced %s", type(_exc).__name__) # noqa: BLE001
298
+
299
+ else:
300
+ # S601: Brave+Tavily come provider primari se API key disponibile;
301
+ # fallback automatico a Wikipedia+DDG+SearXNG se non configurati.
302
+ _brave_res = await _brave_search(q, req.limit)
303
+ _tavily_res = await _tavily_search(q, req.limit)
304
+ if _brave_res or _tavily_res:
305
+ # Provider premium disponibili → usa solo loro + Wikipedia per contesto enciclopedico
306
+ batches = await asyncio.gather(
307
+ asyncio.sleep(0, result=_brave_res),
308
+ asyncio.sleep(0, result=_tavily_res),
309
+ _wiki_search(q, min(req.limit, 3)),
310
+ return_exceptions=True,
311
+ )
312
+ else:
313
+ # Fallback: Wikipedia + DDG + SearXNG (free tier)
314
+ batches = await asyncio.gather(
315
+ _wiki_search(q, req.limit),
316
+ _ddg_html_search(q, req.limit),
317
+ _searxng_search(q, req.limit),
318
+ _ddg_instant_search(q, req.limit),
319
+ return_exceptions=True,
320
+ )
321
+ seen_urls: set[str] = set()
322
+ for batch in batches:
323
+ if isinstance(batch, Exception):
324
+ continue
325
+ for r in batch:
326
+ url = r.get('url', '')
327
+ if url and url not in seen_urls:
328
+ seen_urls.add(url)
329
+ results.append(r)
330
+ elif not url:
331
+ results.append(r)
332
+
333
+ except Exception as e:
334
+ return {'results': [], 'error': str(e)}
335
+
336
+ return {'results': results[:req.limit]}
337
+
338
+
339
+ # ── Fetch page ─────────────────────────────────────────────────────────────────
340
+
341
+ @router.post('/api/fetch-page')
342
+ async def proxy_fetch_page(req: FetchPageRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
343
+ url = req.url.strip()
344
+ if not url.startswith(('http://', 'https://')):
345
+ raise HTTPException(400, detail={'error': 'url_invalido'})
346
+ try:
347
+ import httpx as _httpx_fp
348
+ async with _httpx_fp.AsyncClient(timeout=12, follow_redirects=True) as _hc_fp:
349
+ _r_fp = await _hc_fp.get(url, headers={
350
+ 'User-Agent': 'Mozilla/5.0 (compatible; AgenteAI/3.1; +https://github.com/Baida98/AI)',
351
+ 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
352
+ 'Accept-Language': 'it-IT,it;q=0.9,en;q=0.8',
353
+ })
354
+ raw_bytes = _r_fp.content[:500_000]
355
+ try:
356
+ raw = raw_bytes.decode('utf-8', errors='replace')
357
+ except Exception:
358
+ raw = raw_bytes.decode('latin-1', errors='replace')
359
+ title, code_bks = _get_title(raw), _clean_html(raw)[1]
360
+ text = _clean_html(raw)[0]
361
+ return {'url': url, 'title': title, 'text': text, 'code_blocks': code_bks}
362
+ except Exception as e:
363
+ return {'error': str(e), 'url': url, 'title': '', 'text': '', 'code_blocks': []}
364
+
365
+
366
  # ── Analyze image ──────────────────────────────────────────────────────────────
367
 
368
  @router.post('/api/analyze-image')
369
  async def analyze_image(
370
  body: AnalyzeImageRequest, request: Request,
371
+ role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
372
  ):
373
  """
374
  Vision AI — OpenRouter free VL models → Gemini fallback.
375
+ [S191] rimossi modelli deprecated.
376
  [S-GAP23] X-Internal-Token guard — protegge consumi LLM da abusi esterni.
377
  """
378
  import re as _re2, json as _json
 
424
  }]
425
 
426
  last_err = None
427
+ tried = []
428
+
429
  for (pname, api_key, base_url, model) in VISION_MATRIX:
430
  try:
431
  vclient = _OAI(api_key=api_key, base_url=base_url)
 
447
  'provider': f'{pname}/{model}',
448
  'filename': body.filename,
449
  }
450
+ except _json.JSONDecodeError as _exc:
451
+ _logger.debug("[search] silenced %s", type(_exc).__name__) # noqa: BLE001
452
+ if raw:
453
+ return {
454
+ 'ok': True, 'description': raw[:300], # S584: 250→300
455
+ 'tags': [], 'objects': [], 'text_in_image': None, 'mood': None,
456
+ 'provider': f'{pname}/{model}', 'filename': body.filename,
457
+ }
458
+ except Exception as exc:
459
+ last_err = str(exc)
460
+ tried.append(f'{pname}/{model}: {str(exc)[:200]}') # S608: 60→200
461
  continue
462
 
463
+ return {'ok': False, 'error': f'Tutti i provider vision falliti. Ultimo: {last_err}', 'tried': tried[:5]}
api/web.py CHANGED
@@ -18,6 +18,14 @@ class SearchReq(BaseModel):
18
  class FetchReq(BaseModel):
19
  url:str; query:Optional[str]=None; max_chars:Optional[int]=5000
20
 
 
 
 
 
 
 
 
 
21
  @router.post("/search")
22
  async def search(req:SearchReq, role:AuthRole=Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
23
  raw=await web_search(req.query,req.focus or "general",req.max_results or 6)
@@ -33,4 +41,35 @@ async def fetch(req:FetchReq, role:AuthRole=Depends(require_role(AuthRole.MACHIN
33
  raw["content"]=cleaned["content"]; raw["words"]=cleaned["words"]
34
  return raw
35
 
36
- # Endpoint /proxy-fetch rimosso per conformità alle policy Hugging Face (Reverse Proxy)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  class FetchReq(BaseModel):
19
  url:str; query:Optional[str]=None; max_chars:Optional[int]=5000
20
 
21
+ class ProxyFetchReq(BaseModel):
22
+ """V7-2 GAP-2b: server-side proxy fetch — zero CORS, nessun WAF client-side."""
23
+ url: str
24
+ method: Optional[str] = "GET"
25
+ headers: Optional[dict] = None
26
+ body: Optional[str] = None
27
+ max_chars: Optional[int] = 50_000
28
+
29
  @router.post("/search")
30
  async def search(req:SearchReq, role:AuthRole=Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
31
  raw=await web_search(req.query,req.focus or "general",req.max_results or 6)
 
41
  raw["content"]=cleaned["content"]; raw["words"]=cleaned["words"]
42
  return raw
43
 
44
+ @router.post("/proxy-fetch")
45
+ async def proxy_fetch(req: ProxyFetchReq, role:AuthRole=Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix SSRF
46
+ """
47
+ V7-2 GAP-2b: server-side proxy fetch.
48
+
49
+ Il client iPhone Safari fallisce su ~40-60% dei fetch diretti (CORS, WAF, TLS fingerprint).
50
+ Questo endpoint esegue il fetch lato server (Railway) e lo ritorna al client:
51
+ - Zero CORS (server-to-server)
52
+ - Bypassa WAF client-side
53
+ - httpx con timeout 7s e max_chars 50K
54
+ - Response: {ok:bool, status:int, body:str, url:str} o {ok:false, error:str, url:str}
55
+
56
+ Cold-start Railway free tier: fino a 5s → il client usa timeout 7s.
57
+ """
58
+ try:
59
+ import httpx
60
+ max_chars = min(req.max_chars or 50_000, 100_000) # hard cap 100K
61
+ hdrs = req.headers or {}
62
+ # Imposta un User-Agent realistico per evitare blocchi anti-bot
63
+ if "user-agent" not in {k.lower() for k in hdrs}:
64
+ hdrs = {**hdrs, "User-Agent": "Mozilla/5.0 (compatible; AgentBot/1.0)"}
65
+ async with httpx.AsyncClient(timeout=7.0, follow_redirects=True) as client:
66
+ resp = await client.request(
67
+ method = (req.method or "GET").upper(),
68
+ url = req.url,
69
+ headers = hdrs,
70
+ content = req.body.encode("utf-8") if req.body else None,
71
+ )
72
+ body = resp.text[:max_chars]
73
+ return {"ok": True, "status": resp.status_code, "body": body, "url": req.url}
74
+ except Exception as e:
75
+ return {"ok": False, "error": str(e)[:300], "url": req.url}