Emalawi19 commited on
Commit
7cf448e
Β·
verified Β·
1 Parent(s): 859b549

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +471 -89
app.py CHANGED
@@ -1,13 +1,13 @@
1
- import os, json, subprocess, tempfile, shutil, base64
2
  import bcrypt
3
  from datetime import datetime, timedelta
4
  from pathlib import Path
5
- from typing import Optional
6
 
7
  import httpx
8
  from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Form, Request
9
  from fastapi.middleware.cors import CORSMiddleware
10
- from fastapi.responses import HTMLResponse, Response
11
  from pydantic import BaseModel
12
  from jose import JWTError, jwt
13
 
@@ -15,8 +15,7 @@ from jose import JWTError, jwt
15
  SECRET_KEY = os.environ.get("SECRET_KEY", "php-hosting-secret-key-change-me")
16
  ALGORITHM = "HS256"
17
  TOKEN_EXPIRE_MINS = 60 * 24 * 7
18
-
19
- MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB per file
20
 
21
  # ── GitHub Config ─────────────────────────────────────────────────────────────
22
  GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
@@ -25,11 +24,14 @@ GITHUB_REPO = "Master"
25
  GITHUB_BASE = "PHP/Users"
26
  GITHUB_API = "https://api.github.com"
27
 
28
- # ── Local DB (users only β€” no files stored locally) ───────────────────────────
29
- DATA_DIR = Path("/data")
30
- DB_FILE = DATA_DIR / "db" / "users.json"
 
31
  DB_FILE.parent.mkdir(parents=True, exist_ok=True)
 
32
 
 
33
  def load_db() -> dict:
34
  if DB_FILE.exists():
35
  try:
@@ -68,13 +70,13 @@ def decode_jwt(token: str):
68
  except JWTError:
69
  return None
70
 
71
- # ── GitHub API helpers ─────────────────────────────────────────────────────────
72
  def gh_headers() -> dict:
73
  if not GITHUB_TOKEN:
74
  raise HTTPException(500, "GitHub token not configured")
75
  return {
76
  "Authorization": f"token {GITHUB_TOKEN}",
77
- "Accept": "application/vnd.github+json",
78
  "X-GitHub-Api-Version": "2022-11-28",
79
  }
80
 
@@ -86,6 +88,9 @@ def gh_path(username: str, repo: str = None, filename: str = None) -> str:
86
  path += f"/{filename}"
87
  return path
88
 
 
 
 
89
  async def gh_get_file(path: str) -> Optional[dict]:
90
  url = f"{GITHUB_API}/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/contents/{path}"
91
  async with httpx.AsyncClient(timeout=30) as client:
@@ -132,11 +137,7 @@ async def gh_create_placeholder(path: str, username: str):
132
  placeholder_path = f"{path}/.gitkeep"
133
  existing = await gh_get_file(placeholder_path)
134
  if not existing:
135
- await gh_put_file(
136
- placeholder_path,
137
- b"",
138
- f"Create folder for {username}"
139
- )
140
 
141
  async def gh_ensure_repo_exists():
142
  url = f"{GITHUB_API}/repos/{GITHUB_USERNAME}/{GITHUB_REPO}"
@@ -145,11 +146,62 @@ async def gh_ensure_repo_exists():
145
  if res.status_code == 404:
146
  create_url = f"{GITHUB_API}/user/repos"
147
  await client.post(create_url, headers=gh_headers(), json={
148
- "name": GITHUB_REPO,
149
  "private": True,
150
  "description": "PHP Hosting Storage"
151
  })
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  # ── Auth dependency ────────────────────────────────────────────────────────────
154
  def get_current_user(request: Request) -> dict:
155
  auth = request.headers.get("Authorization", "")
@@ -175,15 +227,14 @@ app.add_middleware(
175
  allow_headers=["*"],
176
  )
177
 
178
- # ── Routes ─────────────────────────────────────────────────────────────────────
179
-
180
  @app.get("/")
181
  def root():
182
- return {"status": "PHP Hosting Backend is running", "storage": "GitHub"}
183
 
184
  @app.get("/health")
185
  def health():
186
- return {"status": "ok", "users": len(load_db()), "storage": "GitHub"}
187
 
188
  # ── Register ───────────────────────────────────────────────────────────────────
189
  class RegisterBody(BaseModel):
@@ -199,18 +250,13 @@ async def register(body: RegisterBody):
199
  raise HTTPException(400, "Username must be letters and numbers only")
200
  if len(body.password) < 8:
201
  raise HTTPException(400, "Password must be at least 8 characters")
202
-
203
  db = load_db()
204
  if body.username in db:
205
  raise HTTPException(400, "Username already taken")
206
-
207
  hashed = hash_password(body.password)
208
-
209
- # Create user folder in GitHub: PHP/Users/username/
210
  await gh_ensure_repo_exists()
211
  user_folder = gh_path(body.username)
212
  await gh_create_placeholder(user_folder, body.username)
213
-
214
  db[body.username] = {
215
  "username": body.username,
216
  "password_hash": hashed,
@@ -218,7 +264,7 @@ async def register(body: RegisterBody):
218
  "github_path": user_folder,
219
  }
220
  save_db(db)
221
-
222
  token = create_jwt({"sub": body.username})
223
  return {
224
  "access_token": token,
@@ -258,43 +304,49 @@ def login(body: LoginBody):
258
  @app.post("/upload")
259
  async def upload_files(
260
  repo: str = Form(...),
 
261
  files: list[UploadFile] = File(...),
262
  user: dict = Depends(get_current_user)
263
  ):
264
  try:
265
  if not repo.isalnum():
266
  raise HTTPException(400, "Repo name must be letters and numbers only")
267
-
268
  uploaded = []
269
  for f in files:
270
  content = await f.read()
271
  if len(content) > MAX_FILE_BYTES:
272
  raise HTTPException(413, f"{f.filename} exceeds 25 MB limit")
273
-
274
- safe_name = Path(f.filename).name
275
- file_path = gh_path(user["username"], repo, safe_name)
276
-
277
- # Check if file already exists (for update)
278
- existing = await gh_get_file(file_path)
279
- sha = existing["sha"] if existing else None
280
-
281
  await gh_put_file(
282
- file_path,
283
- content,
284
- f"Upload {safe_name} to {user['username']}/{repo}",
285
- sha
286
  )
287
-
288
  uploaded.append({
289
  "filename": safe_name,
290
  "size": len(content),
291
  "url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo}/{safe_name}"
292
  })
293
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  return {
295
- "uploaded": uploaded,
296
- "repo": repo,
297
- "repo_url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo}"
 
298
  }
299
  except HTTPException:
300
  raise
@@ -309,14 +361,27 @@ async def list_files(user: dict = Depends(get_current_user)):
309
  items = await gh_list_folder(user_folder)
310
  repos = []
311
  total_size = 0
312
-
313
  for item in items:
314
  if item.get("type") == "dir":
315
  repo_name = item["name"]
 
 
316
  repo_items = await gh_list_folder(f"{user_folder}/{repo_name}")
317
  files = []
 
318
  for fi in repo_items:
319
- if fi.get("type") == "file" and fi["name"] != ".gitkeep":
 
 
 
 
 
 
 
 
 
 
 
320
  size = fi.get("size", 0)
321
  total_size += size
322
  files.append({
@@ -325,13 +390,12 @@ async def list_files(user: dict = Depends(get_current_user)):
325
  "sha": fi["sha"],
326
  "url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo_name}/{fi['name']}"
327
  })
328
- if files:
329
- repos.append({
330
- "repo": repo_name,
331
- "repo_url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo_name}",
332
- "files": files
333
- })
334
-
335
  return {
336
  "username": user["username"],
337
  "repos": repos,
@@ -351,11 +415,7 @@ async def delete_file(repo: str, filename: str, user: dict = Depends(get_current
351
  existing = await gh_get_file(file_path)
352
  if not existing:
353
  raise HTTPException(404, "File not found")
354
- await gh_delete_file(
355
- file_path,
356
- existing["sha"],
357
- f"Delete {filename} from {user['username']}/{repo}"
358
- )
359
  return {"message": f"{filename} deleted"}
360
  except HTTPException:
361
  raise
@@ -387,62 +447,380 @@ async def serve_file(username: str, repo: str, filename: str):
387
  try:
388
  file_path = gh_path(username, repo, filename)
389
  file_info = await gh_get_file(file_path)
390
-
391
  if not file_info:
392
- # Try index.php fallback
393
  if filename == "index.php":
394
  file_info = await gh_get_file(gh_path(username, repo, "index.html"))
395
  if file_info:
396
  filename = "index.html"
397
  if not file_info:
398
  raise HTTPException(404, "File not found")
399
-
400
  content = base64.b64decode(file_info["content"].replace("\n", ""))
401
-
402
  if filename.endswith(".php"):
403
- with tempfile.NamedTemporaryFile(
404
- suffix=".php", delete=False, dir="/tmp"
405
- ) as tmp:
406
  tmp.write(content)
407
  tmp_path = tmp.name
408
  try:
409
  result = subprocess.run(
410
  ["php", tmp_path],
411
- capture_output=True,
412
- text=True,
413
- timeout=30
414
  )
415
  output = result.stdout
416
  if result.returncode != 0:
417
- output = f"<pre style='color:red;font-family:monospace;padding:20px'>PHP Error:\n{result.stderr}</pre>"
418
  except subprocess.TimeoutExpired:
419
- output = "<pre style='color:red'>Error: Script timed out (30s limit)</pre>"
420
  except FileNotFoundError:
421
- output = "<pre style='color:red'>Error: PHP not installed on server</pre>"
422
  finally:
423
  try:
424
  os.unlink(tmp_path)
425
  except:
426
  pass
427
  return HTMLResponse(content=output)
428
-
429
- ext = filename.split(".")[-1].lower()
430
  mime_map = {
431
- "html": "text/html",
432
- "css": "text/css",
433
- "js": "application/javascript",
434
- "json": "application/json",
435
- "png": "image/png",
436
- "jpg": "image/jpeg",
437
- "jpeg": "image/jpeg",
438
- "gif": "image/gif",
439
- "svg": "image/svg+xml",
440
- "txt": "text/plain",
441
- "ico": "image/x-icon",
442
  }
443
  mime = mime_map.get(ext, "application/octet-stream")
444
  return Response(content=content, media_type=mime)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  except HTTPException:
447
  raise
448
  except Exception as e:
@@ -456,17 +834,21 @@ async def storage_stats(user: dict = Depends(get_current_user)):
456
  items = await gh_list_folder(user_folder)
457
  total_size = 0
458
  for item in items:
459
- if item.get("type") == "dir":
460
  repo_items = await gh_list_folder(f"{user_folder}/{item['name']}")
461
  for fi in repo_items:
462
  if fi.get("type") == "file":
463
  total_size += fi.get("size", 0)
 
 
 
464
  return {
465
- "user_used_mb": round(total_size / (1024 * 1024), 2),
466
- "user_limit_mb": 500,
467
- "total_used_gb": round(total_size / (1024 ** 3), 3),
468
- "total_limit_gb": 15,
469
- "percent_used": round((total_size / (500 * 1024 * 1024)) * 100, 2)
 
470
  }
471
  except HTTPException:
472
  raise
 
1
+ import os, json, subprocess, tempfile, shutil, base64, sqlite3, io
2
  import bcrypt
3
  from datetime import datetime, timedelta
4
  from pathlib import Path
5
+ from typing import Optional, List
6
 
7
  import httpx
8
  from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Form, Request
9
  from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import HTMLResponse, Response, StreamingResponse
11
  from pydantic import BaseModel
12
  from jose import JWTError, jwt
13
 
 
15
  SECRET_KEY = os.environ.get("SECRET_KEY", "php-hosting-secret-key-change-me")
16
  ALGORITHM = "HS256"
17
  TOKEN_EXPIRE_MINS = 60 * 24 * 7
18
+ MAX_FILE_BYTES = 25 * 1024 * 1024
 
19
 
20
  # ── GitHub Config ─────────────────────────────────────────────────────────────
21
  GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
 
24
  GITHUB_BASE = "PHP/Users"
25
  GITHUB_API = "https://api.github.com"
26
 
27
+ # ── Local storage ─────────────────────────────────────────────────────────────
28
+ DATA_DIR = Path("/data")
29
+ DB_FILE = DATA_DIR / "db" / "users.json"
30
+ DBS_DIR = DATA_DIR / "databases"
31
  DB_FILE.parent.mkdir(parents=True, exist_ok=True)
32
+ DBS_DIR.mkdir(parents=True, exist_ok=True)
33
 
34
+ # ── User DB (JSON) ────────────────────────────────────────────────────────────
35
  def load_db() -> dict:
36
  if DB_FILE.exists():
37
  try:
 
70
  except JWTError:
71
  return None
72
 
73
+ # ── GitHub helpers ─────────────────────────────────────────────────────────────
74
  def gh_headers() -> dict:
75
  if not GITHUB_TOKEN:
76
  raise HTTPException(500, "GitHub token not configured")
77
  return {
78
  "Authorization": f"token {GITHUB_TOKEN}",
79
+ "Accept": "application/vnd.github+json",
80
  "X-GitHub-Api-Version": "2022-11-28",
81
  }
82
 
 
88
  path += f"/{filename}"
89
  return path
90
 
91
+ def gh_db_path(username: str, dbname: str) -> str:
92
+ return f"{GITHUB_BASE}/{username}/_databases/{dbname}.db"
93
+
94
  async def gh_get_file(path: str) -> Optional[dict]:
95
  url = f"{GITHUB_API}/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/contents/{path}"
96
  async with httpx.AsyncClient(timeout=30) as client:
 
137
  placeholder_path = f"{path}/.gitkeep"
138
  existing = await gh_get_file(placeholder_path)
139
  if not existing:
140
+ await gh_put_file(placeholder_path, b"", f"Create folder for {username}")
 
 
 
 
141
 
142
  async def gh_ensure_repo_exists():
143
  url = f"{GITHUB_API}/repos/{GITHUB_USERNAME}/{GITHUB_REPO}"
 
146
  if res.status_code == 404:
147
  create_url = f"{GITHUB_API}/user/repos"
148
  await client.post(create_url, headers=gh_headers(), json={
149
+ "name": GITHUB_REPO,
150
  "private": True,
151
  "description": "PHP Hosting Storage"
152
  })
153
 
154
+ # ── SQLite database helpers ────────────────────────────────────────────────────
155
+ def get_user_db_dir(username: str) -> Path:
156
+ p = DBS_DIR / username
157
+ p.mkdir(parents=True, exist_ok=True)
158
+ return p
159
+
160
+ def get_db_path(username: str, dbname: str) -> Path:
161
+ return get_user_db_dir(username) / f"{dbname}.db"
162
+
163
+ def get_conn(username: str, dbname: str) -> sqlite3.Connection:
164
+ db_path = get_db_path(username, dbname)
165
+ if not db_path.exists():
166
+ raise HTTPException(404, f"Database '{dbname}' not found")
167
+ conn = sqlite3.connect(str(db_path))
168
+ conn.row_factory = sqlite3.Row
169
+ return conn
170
+
171
+ async def backup_db_to_github(username: str, dbname: str):
172
+ db_path = get_db_path(username, dbname)
173
+ if not db_path.exists():
174
+ return
175
+ content = db_path.read_bytes()
176
+ gh_path_str = gh_db_path(username, dbname)
177
+ existing = await gh_get_file(gh_path_str)
178
+ sha = existing["sha"] if existing else None
179
+ await gh_put_file(
180
+ gh_path_str,
181
+ content,
182
+ f"Backup database {dbname} for {username}",
183
+ sha
184
+ )
185
+
186
+ def list_user_databases(username: str) -> list:
187
+ db_dir = get_user_db_dir(username)
188
+ dbs = []
189
+ for f in sorted(db_dir.glob("*.db")):
190
+ conn = sqlite3.connect(str(f))
191
+ cursor = conn.cursor()
192
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
193
+ tables = [row[0] for row in cursor.fetchall()]
194
+ conn.close()
195
+ size = f.stat().st_size
196
+ dbs.append({
197
+ "name": f.stem,
198
+ "tables": tables,
199
+ "table_count": len(tables),
200
+ "size_kb": round(size / 1024, 2),
201
+ "created_at": datetime.fromtimestamp(f.stat().st_ctime).isoformat(),
202
+ })
203
+ return dbs
204
+
205
  # ── Auth dependency ────────────────────────────────────────────────────────────
206
  def get_current_user(request: Request) -> dict:
207
  auth = request.headers.get("Authorization", "")
 
227
  allow_headers=["*"],
228
  )
229
 
230
+ # ── Health ─────────────────────────────────────────────────────────────────────
 
231
  @app.get("/")
232
  def root():
233
+ return {"status": "PHP Hosting Backend is running", "storage": "GitHub + SQLite"}
234
 
235
  @app.get("/health")
236
  def health():
237
+ return {"status": "ok", "users": len(load_db())}
238
 
239
  # ── Register ───────────────────────────────────────────────────────────────────
240
  class RegisterBody(BaseModel):
 
250
  raise HTTPException(400, "Username must be letters and numbers only")
251
  if len(body.password) < 8:
252
  raise HTTPException(400, "Password must be at least 8 characters")
 
253
  db = load_db()
254
  if body.username in db:
255
  raise HTTPException(400, "Username already taken")
 
256
  hashed = hash_password(body.password)
 
 
257
  await gh_ensure_repo_exists()
258
  user_folder = gh_path(body.username)
259
  await gh_create_placeholder(user_folder, body.username)
 
260
  db[body.username] = {
261
  "username": body.username,
262
  "password_hash": hashed,
 
264
  "github_path": user_folder,
265
  }
266
  save_db(db)
267
+ get_user_db_dir(body.username)
268
  token = create_jwt({"sub": body.username})
269
  return {
270
  "access_token": token,
 
304
  @app.post("/upload")
305
  async def upload_files(
306
  repo: str = Form(...),
307
+ visibility: str = Form("public"),
308
  files: list[UploadFile] = File(...),
309
  user: dict = Depends(get_current_user)
310
  ):
311
  try:
312
  if not repo.isalnum():
313
  raise HTTPException(400, "Repo name must be letters and numbers only")
 
314
  uploaded = []
315
  for f in files:
316
  content = await f.read()
317
  if len(content) > MAX_FILE_BYTES:
318
  raise HTTPException(413, f"{f.filename} exceeds 25 MB limit")
319
+ safe_name = Path(f.filename).name
320
+ file_path = gh_path(user["username"], repo, safe_name)
321
+ existing = await gh_get_file(file_path)
322
+ sha = existing["sha"] if existing else None
 
 
 
 
323
  await gh_put_file(
324
+ file_path, content,
325
+ f"Upload {safe_name} to {user['username']}/{repo}", sha
 
 
326
  )
 
327
  uploaded.append({
328
  "filename": safe_name,
329
  "size": len(content),
330
  "url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo}/{safe_name}"
331
  })
332
 
333
+ # Save visibility metadata
334
+ meta_path = gh_path(user["username"], repo, ".meta.json")
335
+ meta_existing = await gh_get_file(meta_path)
336
+ meta_sha = meta_existing["sha"] if meta_existing else None
337
+ meta = {"visibility": visibility, "updated_at": datetime.utcnow().isoformat()}
338
+ await gh_put_file(
339
+ meta_path,
340
+ json.dumps(meta).encode(),
341
+ f"Update metadata for {user['username']}/{repo}",
342
+ meta_sha
343
+ )
344
+
345
  return {
346
+ "uploaded": uploaded,
347
+ "repo": repo,
348
+ "visibility": visibility,
349
+ "repo_url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo}"
350
  }
351
  except HTTPException:
352
  raise
 
361
  items = await gh_list_folder(user_folder)
362
  repos = []
363
  total_size = 0
 
364
  for item in items:
365
  if item.get("type") == "dir":
366
  repo_name = item["name"]
367
+ if repo_name.startswith("_"):
368
+ continue
369
  repo_items = await gh_list_folder(f"{user_folder}/{repo_name}")
370
  files = []
371
+ visibility = "public"
372
  for fi in repo_items:
373
+ if fi.get("type") == "file":
374
+ if fi["name"] == ".meta.json":
375
+ try:
376
+ meta_info = await gh_get_file(f"{user_folder}/{repo_name}/.meta.json")
377
+ if meta_info:
378
+ meta = json.loads(base64.b64decode(meta_info["content"].replace("\n","")).decode())
379
+ visibility = meta.get("visibility","public")
380
+ except:
381
+ pass
382
+ continue
383
+ if fi["name"] == ".gitkeep":
384
+ continue
385
  size = fi.get("size", 0)
386
  total_size += size
387
  files.append({
 
390
  "sha": fi["sha"],
391
  "url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo_name}/{fi['name']}"
392
  })
393
+ repos.append({
394
+ "repo": repo_name,
395
+ "visibility": visibility,
396
+ "repo_url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo_name}",
397
+ "files": files
398
+ })
 
399
  return {
400
  "username": user["username"],
401
  "repos": repos,
 
415
  existing = await gh_get_file(file_path)
416
  if not existing:
417
  raise HTTPException(404, "File not found")
418
+ await gh_delete_file(file_path, existing["sha"], f"Delete {filename} from {user['username']}/{repo}")
 
 
 
 
419
  return {"message": f"{filename} deleted"}
420
  except HTTPException:
421
  raise
 
447
  try:
448
  file_path = gh_path(username, repo, filename)
449
  file_info = await gh_get_file(file_path)
 
450
  if not file_info:
 
451
  if filename == "index.php":
452
  file_info = await gh_get_file(gh_path(username, repo, "index.html"))
453
  if file_info:
454
  filename = "index.html"
455
  if not file_info:
456
  raise HTTPException(404, "File not found")
 
457
  content = base64.b64decode(file_info["content"].replace("\n", ""))
 
458
  if filename.endswith(".php"):
459
+ with tempfile.NamedTemporaryFile(suffix=".php", delete=False, dir="/tmp") as tmp:
 
 
460
  tmp.write(content)
461
  tmp_path = tmp.name
462
  try:
463
  result = subprocess.run(
464
  ["php", tmp_path],
465
+ capture_output=True, text=True, timeout=30
 
 
466
  )
467
  output = result.stdout
468
  if result.returncode != 0:
469
+ output = f"<pre style='color:red;padding:20px'>PHP Error:\n{result.stderr}</pre>"
470
  except subprocess.TimeoutExpired:
471
+ output = "<pre style='color:red'>Error: Script timed out</pre>"
472
  except FileNotFoundError:
473
+ output = "<pre style='color:red'>Error: PHP not installed</pre>"
474
  finally:
475
  try:
476
  os.unlink(tmp_path)
477
  except:
478
  pass
479
  return HTMLResponse(content=output)
480
+ ext = filename.split(".")[-1].lower()
 
481
  mime_map = {
482
+ "html": "text/html", "css": "text/css",
483
+ "js": "application/javascript", "json": "application/json",
484
+ "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
485
+ "gif": "image/gif", "svg": "image/svg+xml",
486
+ "txt": "text/plain", "ico": "image/x-icon",
 
 
 
 
 
 
487
  }
488
  mime = mime_map.get(ext, "application/octet-stream")
489
  return Response(content=content, media_type=mime)
490
+ except HTTPException:
491
+ raise
492
+ except Exception as e:
493
+ raise HTTPException(500, str(e))
494
+
495
+ # ══════════════════════════════════════════════════════════════════════════════
496
+ # ── DATABASE ROUTES ───────────────────────────────────────────────────────────
497
+ # ══════════════════════════════════════════════════════════════════════════════
498
+
499
+ # ── List user databases ────────────────────────────────────────────────────────
500
+ @app.get("/db")
501
+ def list_databases(user: dict = Depends(get_current_user)):
502
+ try:
503
+ dbs = list_user_databases(user["username"])
504
+ return {"databases": dbs, "count": len(dbs)}
505
+ except HTTPException:
506
+ raise
507
+ except Exception as e:
508
+ raise HTTPException(500, str(e))
509
+
510
+ # ── Create database ────────────────────────────────────────────────────────────
511
+ class CreateDBBody(BaseModel):
512
+ name: str
513
+
514
+ @app.post("/db/create")
515
+ async def create_database(body: CreateDBBody, user: dict = Depends(get_current_user)):
516
+ try:
517
+ if not body.name.replace("_","").isalnum():
518
+ raise HTTPException(400, "Database name must be letters, numbers, underscores only")
519
+ if len(body.name) < 2:
520
+ raise HTTPException(400, "Database name must be at least 2 characters")
521
+ db_path = get_db_path(user["username"], body.name)
522
+ if db_path.exists():
523
+ raise HTTPException(400, f"Database '{body.name}' already exists")
524
+ conn = sqlite3.connect(str(db_path))
525
+ conn.execute("PRAGMA journal_mode=WAL")
526
+ conn.execute("PRAGMA foreign_keys=ON")
527
+ conn.close()
528
+ await backup_db_to_github(user["username"], body.name)
529
+ return {
530
+ "message": f"Database '{body.name}' created successfully",
531
+ "name": body.name,
532
+ "php_code": generate_php_connection(user["username"], body.name)
533
+ }
534
+ except HTTPException:
535
+ raise
536
+ except Exception as e:
537
+ raise HTTPException(500, str(e))
538
+
539
+ # ── Drop database ───────────────────────��──────────────────────────────────────
540
+ @app.delete("/db/{dbname}")
541
+ async def drop_database(dbname: str, user: dict = Depends(get_current_user)):
542
+ try:
543
+ db_path = get_db_path(user["username"], dbname)
544
+ if not db_path.exists():
545
+ raise HTTPException(404, f"Database '{dbname}' not found")
546
+ db_path.unlink()
547
+ gh_path_str = gh_db_path(user["username"], dbname)
548
+ existing = await gh_get_file(gh_path_str)
549
+ if existing:
550
+ await gh_delete_file(gh_path_str, existing["sha"], f"Drop database {dbname}")
551
+ return {"message": f"Database '{dbname}' deleted"}
552
+ except HTTPException:
553
+ raise
554
+ except Exception as e:
555
+ raise HTTPException(500, str(e))
556
+
557
+ # ── List tables ────────────────────────────────────────────────────────────────
558
+ @app.get("/db/{dbname}/tables")
559
+ def list_tables(dbname: str, user: dict = Depends(get_current_user)):
560
+ try:
561
+ conn = get_conn(user["username"], dbname)
562
+ cursor = conn.cursor()
563
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
564
+ tables = []
565
+ for row in cursor.fetchall():
566
+ tname = row[0]
567
+ cursor.execute(f"PRAGMA table_info({tname})")
568
+ cols = [{"name": c[1], "type": c[2], "notnull": bool(c[3]), "pk": bool(c[5])} for c in cursor.fetchall()]
569
+ cursor.execute(f"SELECT COUNT(*) FROM `{tname}`")
570
+ count = cursor.fetchone()[0]
571
+ tables.append({"name": tname, "columns": cols, "row_count": count})
572
+ conn.close()
573
+ return {"tables": tables}
574
+ except HTTPException:
575
+ raise
576
+ except Exception as e:
577
+ raise HTTPException(500, str(e))
578
+
579
+ # ── Create table ───────────────────────────────────────────────────────────────
580
+ class ColumnDef(BaseModel):
581
+ name: str
582
+ type: str
583
+ primary_key: bool = False
584
+ not_null: bool = False
585
+ default: Optional[str] = None
586
+ auto_increment: bool = False
587
+
588
+ class CreateTableBody(BaseModel):
589
+ table_name: str
590
+ columns: List[ColumnDef]
591
+
592
+ @app.post("/db/{dbname}/tables")
593
+ async def create_table(dbname: str, body: CreateTableBody, user: dict = Depends(get_current_user)):
594
+ try:
595
+ if not body.table_name.replace("_","").isalnum():
596
+ raise HTTPException(400, "Table name must be letters, numbers, underscores only")
597
+ col_defs = []
598
+ for col in body.columns:
599
+ col_sql = f"`{col.name}` {col.type}"
600
+ if col.primary_key:
601
+ col_sql += " PRIMARY KEY"
602
+ if col.auto_increment:
603
+ col_sql += " AUTOINCREMENT"
604
+ if col.not_null and not col.primary_key:
605
+ col_sql += " NOT NULL"
606
+ if col.default is not None:
607
+ col_sql += f" DEFAULT {col.default}"
608
+ col_defs.append(col_sql)
609
+ sql = f"CREATE TABLE IF NOT EXISTS `{body.table_name}` ({', '.join(col_defs)})"
610
+ conn = get_conn(user["username"], dbname)
611
+ conn.execute(sql)
612
+ conn.commit()
613
+ conn.close()
614
+ await backup_db_to_github(user["username"], dbname)
615
+ return {"message": f"Table '{body.table_name}' created", "sql": sql}
616
+ except HTTPException:
617
+ raise
618
+ except Exception as e:
619
+ raise HTTPException(500, str(e))
620
+
621
+ # ── Drop table ─────────────────────────────────────────────────────────────────
622
+ @app.delete("/db/{dbname}/tables/{table_name}")
623
+ async def drop_table(dbname: str, table_name: str, user: dict = Depends(get_current_user)):
624
+ try:
625
+ conn = get_conn(user["username"], dbname)
626
+ conn.execute(f"DROP TABLE IF EXISTS `{table_name}`")
627
+ conn.commit()
628
+ conn.close()
629
+ await backup_db_to_github(user["username"], dbname)
630
+ return {"message": f"Table '{table_name}' dropped"}
631
+ except HTTPException:
632
+ raise
633
+ except Exception as e:
634
+ raise HTTPException(500, str(e))
635
+
636
+ # ── Get table data ─────────────────────────────────────────────────────────────
637
+ @app.get("/db/{dbname}/tables/{table_name}/data")
638
+ def get_table_data(
639
+ dbname: str,
640
+ table_name: str,
641
+ limit: int = 100,
642
+ offset: int = 0,
643
+ user: dict = Depends(get_current_user)
644
+ ):
645
+ try:
646
+ conn = get_conn(user["username"], dbname)
647
+ cursor = conn.cursor()
648
+ cursor.execute(f"SELECT COUNT(*) FROM `{table_name}`")
649
+ total = cursor.fetchone()[0]
650
+ cursor.execute(f"SELECT * FROM `{table_name}` LIMIT ? OFFSET ?", (limit, offset))
651
+ rows = cursor.fetchall()
652
+ cols = [d[0] for d in cursor.description]
653
+ data = [dict(zip(cols, row)) for row in rows]
654
+ conn.close()
655
+ return {"columns": cols, "rows": data, "total": total, "limit": limit, "offset": offset}
656
+ except HTTPException:
657
+ raise
658
+ except Exception as e:
659
+ raise HTTPException(500, str(e))
660
+
661
+ # ── Run SQL query ──────────────────────────────────────────────────────────────
662
+ class RunSQLBody(BaseModel):
663
+ sql: str
664
+
665
+ @app.post("/db/{dbname}/query")
666
+ async def run_query(dbname: str, body: RunSQLBody, user: dict = Depends(get_current_user)):
667
+ try:
668
+ sql = body.sql.strip()
669
+ if not sql:
670
+ raise HTTPException(400, "SQL query cannot be empty")
671
+
672
+ # Block dangerous operations
673
+ sql_upper = sql.upper()
674
+ blocked = ["DROP DATABASE", "ATTACH", "DETACH", "PRAGMA"]
675
+ for b in blocked:
676
+ if b in sql_upper:
677
+ raise HTTPException(400, f"Operation '{b}' is not allowed")
678
+
679
+ conn = get_conn(user["username"], dbname)
680
+ cursor = conn.cursor()
681
+
682
+ try:
683
+ cursor.executescript(sql) if ";" in sql and sql.count(";") > 1 else cursor.execute(sql)
684
+ is_select = sql_upper.startswith("SELECT") or sql_upper.startswith("PRAGMA")
685
+ if is_select:
686
+ rows = cursor.fetchall()
687
+ cols = [d[0] for d in cursor.description] if cursor.description else []
688
+ data = [dict(zip(cols, row)) for row in rows]
689
+ conn.close()
690
+ return {
691
+ "type": "select",
692
+ "columns": cols,
693
+ "rows": data,
694
+ "count": len(data)
695
+ }
696
+ else:
697
+ conn.commit()
698
+ affected = cursor.rowcount
699
+ conn.close()
700
+ await backup_db_to_github(user["username"], dbname)
701
+ return {
702
+ "type": "modify",
703
+ "message": "Query executed successfully",
704
+ "affected": affected
705
+ }
706
+ except sqlite3.Error as e:
707
+ conn.close()
708
+ raise HTTPException(400, f"SQL Error: {str(e)}")
709
+
710
+ except HTTPException:
711
+ raise
712
+ except Exception as e:
713
+ raise HTTPException(500, str(e))
714
 
715
+ # ── Insert row ─────────────────────────────────────────────────────────────────
716
+ @app.post("/db/{dbname}/tables/{table_name}/rows")
717
+ async def insert_row(
718
+ dbname: str,
719
+ table_name: str,
720
+ request: Request,
721
+ user: dict = Depends(get_current_user)
722
+ ):
723
+ try:
724
+ row_data = await request.json()
725
+ if not row_data:
726
+ raise HTTPException(400, "Row data cannot be empty")
727
+ cols = ", ".join([f"`{k}`" for k in row_data.keys()])
728
+ placeholders = ", ".join(["?" for _ in row_data])
729
+ values = list(row_data.values())
730
+ sql = f"INSERT INTO `{table_name}` ({cols}) VALUES ({placeholders})"
731
+ conn = get_conn(user["username"], dbname)
732
+ cursor = conn.cursor()
733
+ cursor.execute(sql, values)
734
+ conn.commit()
735
+ last_id = cursor.lastrowid
736
+ conn.close()
737
+ await backup_db_to_github(user["username"], dbname)
738
+ return {"message": "Row inserted", "id": last_id}
739
+ except HTTPException:
740
+ raise
741
+ except Exception as e:
742
+ raise HTTPException(500, str(e))
743
+
744
+ # ── Delete row ─────────────────────────────────────────────────────────────────
745
+ @app.delete("/db/{dbname}/tables/{table_name}/rows/{row_id}")
746
+ async def delete_row(
747
+ dbname: str,
748
+ table_name: str,
749
+ row_id: int,
750
+ pk_col: str = "id",
751
+ user: dict = Depends(get_current_user)
752
+ ):
753
+ try:
754
+ conn = get_conn(user["username"], dbname)
755
+ conn.execute(f"DELETE FROM `{table_name}` WHERE `{pk_col}` = ?", (row_id,))
756
+ conn.commit()
757
+ conn.close()
758
+ await backup_db_to_github(user["username"], dbname)
759
+ return {"message": f"Row {row_id} deleted"}
760
+ except HTTPException:
761
+ raise
762
+ except Exception as e:
763
+ raise HTTPException(500, str(e))
764
+
765
+ # ── Export database as SQL ─────────────────────────────────────────────────────
766
+ @app.get("/db/{dbname}/export")
767
+ def export_database(dbname: str, user: dict = Depends(get_current_user)):
768
+ try:
769
+ db_path = get_db_path(user["username"], dbname)
770
+ if not db_path.exists():
771
+ raise HTTPException(404, "Database not found")
772
+ conn = sqlite3.connect(str(db_path))
773
+ sql_lines = []
774
+ sql_lines.append(f"-- PHP Hosting Database Export")
775
+ sql_lines.append(f"-- Database: {dbname}")
776
+ sql_lines.append(f"-- Exported: {datetime.utcnow().isoformat()}")
777
+ sql_lines.append(f"-- User: {user['username']}")
778
+ sql_lines.append("")
779
+ for line in conn.iterdump():
780
+ sql_lines.append(line)
781
+ conn.close()
782
+ sql_content = "\n".join(sql_lines)
783
+ return Response(
784
+ content=sql_content,
785
+ media_type="application/sql",
786
+ headers={"Content-Disposition": f"attachment; filename={dbname}.sql"}
787
+ )
788
+ except HTTPException:
789
+ raise
790
+ except Exception as e:
791
+ raise HTTPException(500, str(e))
792
+
793
+ # ── Generate PHP connection code ───────────────────────────────────────────────
794
+ def generate_php_connection(username: str, dbname: str) -> str:
795
+ return f'''<?php
796
+ // ── Database Connection ────────────────────────────────────────────
797
+ // Database: {dbname}
798
+ // Generated by PHP Hosting Platform
799
+
800
+ $db_path = __DIR__ . "/{dbname}.db";
801
+
802
+ try {{
803
+ $pdo = new PDO("sqlite:" . $db_path);
804
+ $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
805
+ $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
806
+ // Connection successful!
807
+ }} catch(PDOException $e) {{
808
+ die("Connection failed: " . $e->getMessage());
809
+ }}
810
+
811
+ // Example usage:
812
+ // $stmt = $pdo->prepare("SELECT * FROM your_table");
813
+ // $stmt->execute();
814
+ // $rows = $stmt->fetchAll();
815
+ ?>'''
816
+
817
+ @app.get("/db/{dbname}/phpcode")
818
+ def get_php_connection_code(dbname: str, user: dict = Depends(get_current_user)):
819
+ try:
820
+ db_path = get_db_path(user["username"], dbname)
821
+ if not db_path.exists():
822
+ raise HTTPException(404, "Database not found")
823
+ return {"php_code": generate_php_connection(user["username"], dbname)}
824
  except HTTPException:
825
  raise
826
  except Exception as e:
 
834
  items = await gh_list_folder(user_folder)
835
  total_size = 0
836
  for item in items:
837
+ if item.get("type") == "dir" and not item["name"].startswith("_"):
838
  repo_items = await gh_list_folder(f"{user_folder}/{item['name']}")
839
  for fi in repo_items:
840
  if fi.get("type") == "file":
841
  total_size += fi.get("size", 0)
842
+ db_dir = get_user_db_dir(user["username"])
843
+ db_size = sum(f.stat().st_size for f in db_dir.glob("*.db"))
844
+ dbs = list_user_databases(user["username"])
845
  return {
846
+ "files_used_mb": round(total_size / (1024 * 1024), 2),
847
+ "db_used_mb": round(db_size / (1024 * 1024), 2),
848
+ "total_used_mb": round((total_size + db_size) / (1024 * 1024), 2),
849
+ "limit_mb": 500,
850
+ "percent_used": round(((total_size + db_size) / (500 * 1024 * 1024)) * 100, 2),
851
+ "databases": len(dbs)
852
  }
853
  except HTTPException:
854
  raise