Emalawi19 commited on
Commit
a1828d0
Β·
verified Β·
1 Parent(s): 10ad21b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +195 -98
app.py CHANGED
@@ -1,12 +1,13 @@
1
- import os, io, json, subprocess, tempfile, secrets, shutil
2
  import bcrypt
3
  from datetime import datetime, timedelta
4
- from typing import Optional
5
  from pathlib import Path
 
6
 
 
7
  from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Form, Request
8
  from fastapi.middleware.cors import CORSMiddleware
9
- from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse, Response
10
  from pydantic import BaseModel
11
  from jose import JWTError, jwt
12
 
@@ -15,19 +16,20 @@ SECRET_KEY = os.environ.get("SECRET_KEY", "php-hosting-secret-key-change-
15
  ALGORITHM = "HS256"
16
  TOKEN_EXPIRE_MINS = 60 * 24 * 7
17
 
18
- MAX_FILE_BYTES = 25 * 1024 * 1024
19
- MAX_USER_BYTES = 500 * 1024 * 1024
20
- MAX_TOTAL_BYTES = 15 * 1024 * 1024 * 1024
21
 
22
- # ── Storage paths ─────────────────────────────────────────────────────────────
23
- DATA_DIR = Path("/data")
24
- USERS_DIR = DATA_DIR / "users"
25
- DB_FILE = DATA_DIR / "db" / "users.json"
 
 
26
 
27
- USERS_DIR.mkdir(parents=True, exist_ok=True)
 
 
28
  DB_FILE.parent.mkdir(parents=True, exist_ok=True)
29
 
30
- # ── Database ──────────────────────────────────────────────────────────────────
31
  def load_db() -> dict:
32
  if DB_FILE.exists():
33
  try:
@@ -42,18 +44,15 @@ def save_db(db: dict):
42
  # ── Security ──────────────────────────────────────────────────────────────────
43
  def hash_password(p: str) -> str:
44
  try:
45
- # Encode to bytes and safely truncate to 72 bytes (bcrypt's limit)
46
- pwd_bytes = p.encode('utf-8')[:72]
47
  salt = bcrypt.gensalt(rounds=10)
48
- return bcrypt.hashpw(pwd_bytes, salt).decode('utf-8')
49
  except Exception as e:
50
  raise HTTPException(500, f"Password hashing error: {e}")
51
 
52
  def verify_password(plain: str, hashed: str) -> bool:
53
  try:
54
- pwd_bytes = plain.encode('utf-8')[:72]
55
- hashed_bytes = hashed.encode('utf-8')
56
- return bcrypt.checkpw(pwd_bytes, hashed_bytes)
57
  except:
58
  return False
59
 
@@ -69,27 +68,87 @@ def decode_jwt(token: str):
69
  except JWTError:
70
  return None
71
 
72
- # ── Storage helpers ────────────────────────────────────────────────────────────
73
- def user_dir(username: str) -> Path:
74
- p = USERS_DIR / username
75
- p.mkdir(parents=True, exist_ok=True)
76
- return p
77
-
78
- def repo_dir(username: str, repo: str) -> Path:
79
- p = user_dir(username) / repo
80
- p.mkdir(parents=True, exist_ok=True)
81
- return p
82
-
83
- def get_dir_size(path: Path) -> int:
84
- try:
85
- return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
86
- except:
87
- return 0
88
-
89
- def get_total_storage() -> int:
90
- if USERS_DIR.exists():
91
- return get_dir_size(USERS_DIR)
92
- return 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  # ── Auth dependency ────────────────────────────────────────────────────────────
95
  def get_current_user(request: Request) -> dict:
@@ -120,11 +179,11 @@ app.add_middleware(
120
 
121
  @app.get("/")
122
  def root():
123
- return {"status": "PHP Hosting Backend is running"}
124
 
125
  @app.get("/health")
126
  def health():
127
- return {"status": "ok", "users": len(load_db())}
128
 
129
  # ── Register ───────────────────────────────────────────────────────────────────
130
  class RegisterBody(BaseModel):
@@ -132,7 +191,7 @@ class RegisterBody(BaseModel):
132
  password: str
133
 
134
  @app.post("/auth/register")
135
- def register(body: RegisterBody):
136
  try:
137
  if len(body.username) < 3:
138
  raise HTTPException(400, "Username must be at least 3 characters")
@@ -142,19 +201,23 @@ def register(body: RegisterBody):
142
  raise HTTPException(400, "Password must be at least 8 characters")
143
 
144
  db = load_db()
145
-
146
  if body.username in db:
147
  raise HTTPException(400, "Username already taken")
148
 
149
  hashed = hash_password(body.password)
150
 
 
 
 
 
 
151
  db[body.username] = {
152
  "username": body.username,
153
  "password_hash": hashed,
154
  "created_at": datetime.utcnow().isoformat(),
 
155
  }
156
  save_db(db)
157
- user_dir(body.username)
158
 
159
  token = create_jwt({"sub": body.username})
160
  return {
@@ -202,25 +265,26 @@ async def upload_files(
202
  if not repo.isalnum():
203
  raise HTTPException(400, "Repo name must be letters and numbers only")
204
 
205
- total = get_total_storage()
206
- if total >= MAX_TOTAL_BYTES:
207
- raise HTTPException(507, "Platform storage full. Contact admin.")
208
-
209
- udir = user_dir(user["username"])
210
- user_used = get_dir_size(udir)
211
- if user_used >= MAX_USER_BYTES:
212
- raise HTTPException(507, "Your storage limit (500 MB) reached.")
213
-
214
- rdir = repo_dir(user["username"], repo)
215
  uploaded = []
216
-
217
  for f in files:
218
  content = await f.read()
219
  if len(content) > MAX_FILE_BYTES:
220
  raise HTTPException(413, f"{f.filename} exceeds 25 MB limit")
221
- safe_name = Path(f.filename).name
222
- file_path = rdir / safe_name
223
- file_path.write_bytes(content)
 
 
 
 
 
 
 
 
 
 
 
 
224
  uploaded.append({
225
  "filename": safe_name,
226
  "size": len(content),
@@ -239,30 +303,39 @@ async def upload_files(
239
 
240
  # ── List repos and files ───────────────────────────────────────────────────────
241
  @app.get("/files")
242
- def list_files(user: dict = Depends(get_current_user)):
243
  try:
244
- udir = user_dir(user["username"])
245
- repos = []
246
- for repo_path in sorted(udir.iterdir()):
247
- if repo_path.is_dir():
248
- files = []
249
- for fp in sorted(repo_path.iterdir()):
250
- if fp.is_file():
 
 
 
 
 
 
 
251
  files.append({
252
- "filename": fp.name,
253
- "size": fp.stat().st_size,
254
- "url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo_path.name}/{fp.name}"
 
255
  })
256
- repos.append({
257
- "repo": repo_path.name,
258
- "repo_url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo_path.name}",
259
- "files": files
260
- })
261
- used = get_dir_size(udir)
 
262
  return {
263
  "username": user["username"],
264
  "repos": repos,
265
- "used_mb": round(used / (1024 * 1024), 2),
266
  "limit_mb": 500
267
  }
268
  except HTTPException:
@@ -272,12 +345,17 @@ def list_files(user: dict = Depends(get_current_user)):
272
 
273
  # ── Delete file ────────────────────────────────────────────────────────────────
274
  @app.delete("/files/{repo}/{filename}")
275
- def delete_file(repo: str, filename: str, user: dict = Depends(get_current_user)):
276
  try:
277
- fp = USERS_DIR / user["username"] / repo / filename
278
- if not fp.exists():
 
279
  raise HTTPException(404, "File not found")
280
- fp.unlink()
 
 
 
 
281
  return {"message": f"{filename} deleted"}
282
  except HTTPException:
283
  raise
@@ -286,12 +364,17 @@ def delete_file(repo: str, filename: str, user: dict = Depends(get_current_user)
286
 
287
  # ── Delete repo ────────────────────────────────────────────────────────────────
288
  @app.delete("/repo/{repo}")
289
- def delete_repo(repo: str, user: dict = Depends(get_current_user)):
290
  try:
291
- rdir = USERS_DIR / user["username"] / repo
292
- if not rdir.exists():
293
- raise HTTPException(404, "Repo not found")
294
- shutil.rmtree(rdir)
 
 
 
 
 
295
  return {"message": f"Repo {repo} deleted"}
296
  except HTTPException:
297
  raise
@@ -300,13 +383,21 @@ def delete_repo(repo: str, user: dict = Depends(get_current_user)):
300
 
301
  # ── Serve PHP / static files ───────────────────────────────────────────────────
302
  @app.get("/serve/{username}/{repo}/{filename:path}")
303
- def serve_file(username: str, repo: str, filename: str):
304
  try:
305
- file_path = USERS_DIR / username / repo / filename
306
- if not file_path.exists():
307
- raise HTTPException(404, "File not found")
 
 
 
 
 
 
 
 
308
 
309
- content = file_path.read_bytes()
310
 
311
  if filename.endswith(".php"):
312
  with tempfile.NamedTemporaryFile(
@@ -335,7 +426,7 @@ def serve_file(username: str, repo: str, filename: str):
335
  pass
336
  return HTMLResponse(content=output)
337
 
338
- ext = filename.split(".")[-1].lower()
339
  mime_map = {
340
  "html": "text/html",
341
  "css": "text/css",
@@ -359,17 +450,23 @@ def serve_file(username: str, repo: str, filename: str):
359
 
360
  # ── Storage stats ──────────────────────────────────────────────────────────────
361
  @app.get("/storage")
362
- def storage_stats(user: dict = Depends(get_current_user)):
363
  try:
364
- udir = user_dir(user["username"])
365
- user_used = get_dir_size(udir)
366
- total_used = get_total_storage()
 
 
 
 
 
 
367
  return {
368
- "user_used_mb": round(user_used / (1024 * 1024), 2),
369
  "user_limit_mb": 500,
370
- "total_used_gb": round(total_used / (1024 ** 3), 3),
371
  "total_limit_gb": 15,
372
- "percent_used": round((user_used / MAX_USER_BYTES) * 100, 2)
373
  }
374
  except HTTPException:
375
  raise
 
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
 
 
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")
23
+ GITHUB_USERNAME = os.environ.get("GITHUB_USERNAME")
24
+ 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:
 
44
  # ── Security ──────────────────────────────────────────────────────────────────
45
  def hash_password(p: str) -> str:
46
  try:
47
+ pwd_bytes = p.encode("utf-8")[:72]
 
48
  salt = bcrypt.gensalt(rounds=10)
49
+ return bcrypt.hashpw(pwd_bytes, salt).decode("utf-8")
50
  except Exception as e:
51
  raise HTTPException(500, f"Password hashing error: {e}")
52
 
53
  def verify_password(plain: str, hashed: str) -> bool:
54
  try:
55
+ return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("utf-8"))
 
 
56
  except:
57
  return False
58
 
 
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
+
81
+ def gh_path(username: str, repo: str = None, filename: str = None) -> str:
82
+ path = f"{GITHUB_BASE}/{username}"
83
+ if repo:
84
+ path += f"/{repo}"
85
+ if filename:
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:
92
+ res = await client.get(url, headers=gh_headers())
93
+ if res.status_code == 404:
94
+ return None
95
+ if res.status_code != 200:
96
+ raise HTTPException(res.status_code, f"GitHub error: {res.text}")
97
+ return res.json()
98
+
99
+ async def gh_put_file(path: str, content_bytes: bytes, message: str, sha: str = None):
100
+ url = f"{GITHUB_API}/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/contents/{path}"
101
+ body = {
102
+ "message": message,
103
+ "content": base64.b64encode(content_bytes).decode("utf-8"),
104
+ }
105
+ if sha:
106
+ body["sha"] = sha
107
+ async with httpx.AsyncClient(timeout=60) as client:
108
+ res = await client.put(url, headers=gh_headers(), json=body)
109
+ if res.status_code not in (200, 201):
110
+ raise HTTPException(res.status_code, f"GitHub upload error: {res.text}")
111
+ return res.json()
112
+
113
+ async def gh_delete_file(path: str, sha: str, message: str):
114
+ url = f"{GITHUB_API}/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/contents/{path}"
115
+ body = {"message": message, "sha": sha}
116
+ async with httpx.AsyncClient(timeout=30) as client:
117
+ res = await client.delete(url, headers=gh_headers(), json=body)
118
+ if res.status_code not in (200, 204):
119
+ raise HTTPException(res.status_code, f"GitHub delete error: {res.text}")
120
+
121
+ async def gh_list_folder(path: str) -> list:
122
+ url = f"{GITHUB_API}/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/contents/{path}"
123
+ async with httpx.AsyncClient(timeout=30) as client:
124
+ res = await client.get(url, headers=gh_headers())
125
+ if res.status_code == 404:
126
+ return []
127
+ if res.status_code != 200:
128
+ return []
129
+ return res.json()
130
+
131
+ 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}"
143
+ async with httpx.AsyncClient(timeout=30) as client:
144
+ res = await client.get(url, headers=gh_headers())
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:
 
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):
 
191
  password: str
192
 
193
  @app.post("/auth/register")
194
+ async def register(body: RegisterBody):
195
  try:
196
  if len(body.username) < 3:
197
  raise HTTPException(400, "Username must be at least 3 characters")
 
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,
217
  "created_at": datetime.utcnow().isoformat(),
218
+ "github_path": user_folder,
219
  }
220
  save_db(db)
 
221
 
222
  token = create_jwt({"sub": body.username})
223
  return {
 
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),
 
303
 
304
  # ── List repos and files ───────────────────────────────────────────────────────
305
  @app.get("/files")
306
+ async def list_files(user: dict = Depends(get_current_user)):
307
  try:
308
+ user_folder = gh_path(user["username"])
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({
323
+ "filename": fi["name"],
324
+ "size": size,
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,
338
+ "used_mb": round(total_size / (1024 * 1024), 2),
339
  "limit_mb": 500
340
  }
341
  except HTTPException:
 
345
 
346
  # ── Delete file ────────────────────────────────────────────────────────────────
347
  @app.delete("/files/{repo}/{filename}")
348
+ async def delete_file(repo: str, filename: str, user: dict = Depends(get_current_user)):
349
  try:
350
+ file_path = gh_path(user["username"], repo, filename)
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
 
364
 
365
  # ── Delete repo ────────────────────────────────────────────────────────────────
366
  @app.delete("/repo/{repo}")
367
+ async def delete_repo(repo: str, user: dict = Depends(get_current_user)):
368
  try:
369
+ repo_folder = gh_path(user["username"], repo)
370
+ items = await gh_list_folder(repo_folder)
371
+ for item in items:
372
+ if item.get("type") == "file":
373
+ await gh_delete_file(
374
+ f"{repo_folder}/{item['name']}",
375
+ item["sha"],
376
+ f"Delete {item['name']} from {user['username']}/{repo}"
377
+ )
378
  return {"message": f"Repo {repo} deleted"}
379
  except HTTPException:
380
  raise
 
383
 
384
  # ── Serve PHP / static files ───────────────────────────────────────────────────
385
  @app.get("/serve/{username}/{repo}/{filename:path}")
386
+ 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(
 
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",
 
450
 
451
  # ── Storage stats ──────────────────────────────────────────────────────────────
452
  @app.get("/storage")
453
+ async def storage_stats(user: dict = Depends(get_current_user)):
454
  try:
455
+ user_folder = gh_path(user["username"])
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