kacapower commited on
Commit
5dfe266
·
1 Parent(s): 7a31669

feat: add VS Code tree explorer APIs and direct download endpoints

Browse files
Files changed (1) hide show
  1. app.py +150 -14
app.py CHANGED
@@ -1,11 +1,18 @@
1
- """OpenCode Full-Screen Gateway for Hugging Face Space.
2
 
3
  Features:
4
- - Pure Full-Screen UI: Proxies directly to native OpenCode web UI with zero redundant sidebars.
5
- - Strict Security Gate: Unauthenticated requests see a locked authentication challenge.
6
- - Multi-factor support: Accepts valid dynamic 8-digit PIN and/or Master Authorization Token.
7
- - Internal loopback Basic Auth automated injection: Eliminates 401 Basic Realm prompt.
8
- - Frame Embedding: Strips X-Frame-Options and restrictive CSP so authorized parent apps embed cleanly.
 
 
 
 
 
 
 
9
  - Full WebSocket & Streaming Support.
10
  - Bucket persistence to kacapower/ghar-storage.
11
  """
@@ -26,7 +33,7 @@ from typing import Optional
26
  import httpx
27
  from fastapi import FastAPI, HTTPException, Header, Query, Request, Response, WebSocket, WebSocketDisconnect
28
  from fastapi.middleware.cors import CORSMiddleware
29
- from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
30
  from starlette.background import BackgroundTask
31
 
32
  try:
@@ -55,6 +62,9 @@ ACCESS_PIN = os.getenv("ACCESS_PIN", "").strip()
55
  HF_READ_TOKEN = os.getenv("HF_READ_TOKEN", "").strip() or os.getenv("HF_TOKEN", "").strip()
56
  DIRECT_SESSION_TIMEOUT = int(os.getenv("DIRECT_SESSION_TIMEOUT", "600"))
57
 
 
 
 
58
  state = {
59
  "last_direct_session": 0.0,
60
  "direct_mode_pause_external": os.getenv("DIRECT_MODE_PAUSE", "1") in {"1", "true", "True"},
@@ -171,6 +181,46 @@ button:hover {{ background:#2ea043; }}
171
  </html>"""
172
 
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  # ---------------- HEALTH ENDPOINT ----------------
175
 
176
  @app.get("/__health")
@@ -190,11 +240,103 @@ async def health(request: Request):
190
  }
191
 
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  # ---------------- PROXY TO OPENCODE ----------------
194
 
195
  def _proxy_headers(request: Request) -> dict:
196
  headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
197
- # Inject internal Basic Auth so OpenCode server on 127.0.0.1 never rejects with 401
198
  headers["authorization"] = INTERNAL_BASIC_AUTH
199
  return headers
200
 
@@ -212,14 +354,12 @@ def _response_headers(upstream: httpx.Response) -> dict:
212
  methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
213
  )
214
  async def proxy(path: str, request: Request):
215
- # Verify authentication
216
  if not is_auth_valid(request):
217
  if request.method == "GET" and "text/html" in request.headers.get("accept", ""):
218
  err = "Invalid PIN" if request.query_params.get("pin") else ""
219
  return HTMLResponse(get_lock_html(err), status_code=401 if err else 200)
220
  return JSONResponse({"error": "Unauthorized: 8-digit PIN or valid authorization required"}, status_code=401)
221
 
222
- # Clean query parameters for upstream OpenCode
223
  params = [(k, v) for k, v in request.query_params.multi_items() if k not in {"pin", "token", "iframe"}]
224
  url = httpx.URL(f"{UPSTREAM}/{path}", params=params) if params else f"{UPSTREAM}/{path}"
225
 
@@ -244,7 +384,6 @@ async def proxy(path: str, request: Request):
244
  background=BackgroundTask(upstream.aclose),
245
  )
246
 
247
- # Persist auth cookie for iframe and sub-requests
248
  pin_val = request.query_params.get("pin") or request.cookies.get("opencode_auth")
249
  if pin_val and is_pin_valid(pin_val):
250
  resp.set_cookie("opencode_auth", pin_val, max_age=86400, httponly=True, samesite="none", secure=True)
@@ -258,7 +397,6 @@ async def ws_proxy(websocket: WebSocket, path: str):
258
  await websocket.close(code=1011)
259
  return
260
 
261
- # Check WebSocket authorization
262
  pin_val = websocket.query_params.get("pin") or websocket.cookies.get("opencode_auth")
263
  token_val = websocket.query_params.get("token")
264
  auth_ok = is_pin_valid(pin_val) or (HF_READ_TOKEN and token_val == HF_READ_TOKEN)
@@ -273,8 +411,6 @@ async def ws_proxy(websocket: WebSocket, path: str):
273
  query = "&".join(f"{k}={v}" for k, v in websocket.query_params.items() if k not in {"pin", "token"})
274
  target = f"{UPSTREAM_WS}/{path}" + (f"?{query}" if query else "")
275
  subprotocols = [p.strip() for p in protocol.split(",")] if protocol else None
276
-
277
- # Forward with Basic Auth headers
278
  extra_headers = {"Authorization": INTERNAL_BASIC_AUTH}
279
 
280
  try:
 
1
+ """OpenCode Gateway & File Explorer for Hugging Face Space.
2
 
3
  Features:
4
+ - Pure Full-Screen UI: Proxies directly to native OpenCode web UI.
5
+ - VS Code Style File Explorer API:
6
+ * GET /__api/tree: Lists folders & files recursively with metadata.
7
+ * GET /__api/file: Previews text/code content.
8
+ - Direct Download Endpoints:
9
+ * GET /dl/zip: One-tap ZIP archive download of the entire workspace.
10
+ * GET /dl/{path}: Direct file download with Content-Disposition attachment.
11
+ - Strict Security Gatekeeper:
12
+ * Unauthenticated requests see a locked authentication challenge.
13
+ * Accepts valid dynamic 8-digit PIN and/or Master Authorization Token.
14
+ * Internal loopback Basic Auth automated injection (eliminates 401 Basic Realm prompt).
15
+ * Frame Embedding: Strips X-Frame-Options & restrictive CSP.
16
  - Full WebSocket & Streaming Support.
17
  - Bucket persistence to kacapower/ghar-storage.
18
  """
 
33
  import httpx
34
  from fastapi import FastAPI, HTTPException, Header, Query, Request, Response, WebSocket, WebSocketDisconnect
35
  from fastapi.middleware.cors import CORSMiddleware
36
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
37
  from starlette.background import BackgroundTask
38
 
39
  try:
 
62
  HF_READ_TOKEN = os.getenv("HF_READ_TOKEN", "").strip() or os.getenv("HF_TOKEN", "").strip()
63
  DIRECT_SESSION_TIMEOUT = int(os.getenv("DIRECT_SESSION_TIMEOUT", "600"))
64
 
65
+ TEXT_LIMIT = 512 * 1024
66
+ ZIP_LIMIT = 500 * 1024 * 1024
67
+
68
  state = {
69
  "last_direct_session": 0.0,
70
  "direct_mode_pause_external": os.getenv("DIRECT_MODE_PAUSE", "1") in {"1", "true", "True"},
 
181
  </html>"""
182
 
183
 
184
+ # ---------------- PATH HELPERS ----------------
185
+
186
+ def _root(root: str = "workspace") -> Path:
187
+ if root not in ROOTS:
188
+ raise HTTPException(status_code=404, detail=f"unknown root {root!r}")
189
+ return ROOTS[root].resolve()
190
+
191
+
192
+ def _resolve(root: str, rel: str) -> Path:
193
+ base = _root(root)
194
+ rel = rel.lstrip("/")
195
+ target = (base / rel).resolve() if rel else base
196
+ if target != base and base not in target.parents:
197
+ raise HTTPException(status_code=403, detail="path escapes root")
198
+ return target
199
+
200
+
201
+ def _rel(base: Path, target: Path) -> str:
202
+ try:
203
+ rel = target.relative_to(base).as_posix()
204
+ except ValueError:
205
+ return ""
206
+ return "" if rel == "." else rel
207
+
208
+
209
+ def _entry(base: Path, p: Path) -> dict:
210
+ try:
211
+ st = p.stat()
212
+ except OSError:
213
+ st = None
214
+ is_dir = p.is_dir()
215
+ return {
216
+ "name": p.name,
217
+ "path": _rel(base, p),
218
+ "type": "dir" if is_dir else "file",
219
+ "size": 0 if is_dir or st is None else st.st_size,
220
+ "mtime": 0 if st is None else int(st.st_mtime),
221
+ }
222
+
223
+
224
  # ---------------- HEALTH ENDPOINT ----------------
225
 
226
  @app.get("/__health")
 
240
  }
241
 
242
 
243
+ # ---------------- VS CODE FILE EXPLORER APIS ----------------
244
+
245
+ @app.get("/__api/tree")
246
+ async def api_tree(request: Request, path: str = ""):
247
+ if not is_auth_valid(request):
248
+ raise HTTPException(status_code=401, detail="Unauthorized")
249
+
250
+ base = _root("workspace")
251
+ target = _resolve("workspace", path)
252
+ if not target.exists():
253
+ raise HTTPException(status_code=404, detail="not found")
254
+
255
+ entries = []
256
+ try:
257
+ children = list(target.iterdir())
258
+ except OSError as exc:
259
+ raise HTTPException(status_code=403, detail=str(exc))
260
+
261
+ for child in children:
262
+ # Hide internal git and cache folders from VS Code explorer view
263
+ if child.name in {".git", "__pycache__", "node_modules"}:
264
+ continue
265
+ if not child.is_symlink() or child.exists():
266
+ entries.append(_entry(base, child))
267
+
268
+ entries.sort(key=lambda e: (e["type"] != "dir", e["name"].lower()))
269
+ return {"path": _rel(base, target), "entries": entries}
270
+
271
+
272
+ @app.get("/__api/file")
273
+ async def api_file(request: Request, path: str = ""):
274
+ if not is_auth_valid(request):
275
+ raise HTTPException(status_code=401, detail="Unauthorized")
276
+
277
+ target = _resolve("workspace", path)
278
+ if not target.is_file():
279
+ raise HTTPException(status_code=404, detail="not a file")
280
+
281
+ size = target.stat().st_size
282
+ with target.open("rb") as fh:
283
+ head = fh.read(4096)
284
+ binary = b"\x00" in head
285
+
286
+ payload = {
287
+ "name": target.name,
288
+ "path": _rel(_root("workspace"), target),
289
+ "size": size,
290
+ "binary": binary,
291
+ "content": None,
292
+ }
293
+ if not binary:
294
+ with target.open("rb") as fh:
295
+ raw = fh.read(TEXT_LIMIT + 1)
296
+ payload["content"] = raw[:TEXT_LIMIT].decode("utf-8", errors="replace")
297
+ return payload
298
+
299
+
300
+ # ---------------- DIRECT DOWNLOAD ROUTES ----------------
301
+
302
+ @app.get("/dl/zip")
303
+ async def download_workspace_zip(request: Request):
304
+ if not is_auth_valid(request):
305
+ raise HTTPException(status_code=401, detail="Unauthorized")
306
+
307
+ base = _root("workspace")
308
+ buf = BytesIO()
309
+ total = 0
310
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
311
+ for child in sorted(base.rglob("*")):
312
+ if child.is_file() and not any(part in {".git", "__pycache__", "node_modules"} for part in child.parts):
313
+ total += child.stat().st_size
314
+ if total > ZIP_LIMIT:
315
+ raise HTTPException(status_code=413, detail="Workspace archive exceeds size limit")
316
+ zf.write(child, child.relative_to(base).as_posix())
317
+
318
+ buf.seek(0)
319
+ filename = f"workspace_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
320
+ headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
321
+ return StreamingResponse(buf, media_type="application/zip", headers=headers)
322
+
323
+
324
+ @app.get("/dl/{path:path}")
325
+ async def direct_file_download(path: str, request: Request):
326
+ if not is_auth_valid(request):
327
+ raise HTTPException(status_code=401, detail="Unauthorized")
328
+
329
+ target = _resolve("workspace", path)
330
+ if not target.is_file():
331
+ raise HTTPException(status_code=404, detail="File not found")
332
+
333
+ return FileResponse(target, filename=target.name, media_type="application/octet-stream")
334
+
335
+
336
  # ---------------- PROXY TO OPENCODE ----------------
337
 
338
  def _proxy_headers(request: Request) -> dict:
339
  headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_HEADERS}
 
340
  headers["authorization"] = INTERNAL_BASIC_AUTH
341
  return headers
342
 
 
354
  methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
355
  )
356
  async def proxy(path: str, request: Request):
 
357
  if not is_auth_valid(request):
358
  if request.method == "GET" and "text/html" in request.headers.get("accept", ""):
359
  err = "Invalid PIN" if request.query_params.get("pin") else ""
360
  return HTMLResponse(get_lock_html(err), status_code=401 if err else 200)
361
  return JSONResponse({"error": "Unauthorized: 8-digit PIN or valid authorization required"}, status_code=401)
362
 
 
363
  params = [(k, v) for k, v in request.query_params.multi_items() if k not in {"pin", "token", "iframe"}]
364
  url = httpx.URL(f"{UPSTREAM}/{path}", params=params) if params else f"{UPSTREAM}/{path}"
365
 
 
384
  background=BackgroundTask(upstream.aclose),
385
  )
386
 
 
387
  pin_val = request.query_params.get("pin") or request.cookies.get("opencode_auth")
388
  if pin_val and is_pin_valid(pin_val):
389
  resp.set_cookie("opencode_auth", pin_val, max_age=86400, httponly=True, samesite="none", secure=True)
 
397
  await websocket.close(code=1011)
398
  return
399
 
 
400
  pin_val = websocket.query_params.get("pin") or websocket.cookies.get("opencode_auth")
401
  token_val = websocket.query_params.get("token")
402
  auth_ok = is_pin_valid(pin_val) or (HF_READ_TOKEN and token_val == HF_READ_TOKEN)
 
411
  query = "&".join(f"{k}={v}" for k, v in websocket.query_params.items() if k not in {"pin", "token"})
412
  target = f"{UPSTREAM_WS}/{path}" + (f"?{query}" if query else "")
413
  subprotocols = [p.strip() for p in protocol.split(",")] if protocol else None
 
 
414
  extra_headers = {"Authorization": INTERNAL_BASIC_AUTH}
415
 
416
  try: