Emalawi19 commited on
Commit
ff3a2a9
Β·
verified Β·
1 Parent(s): 9e64fd3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +241 -173
app.py CHANGED
@@ -5,7 +5,7 @@ from pathlib import Path
5
 
6
  from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Form, Request
7
  from fastapi.middleware.cors import CORSMiddleware
8
- from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse
9
  from pydantic import BaseModel
10
  from passlib.context import CryptContext
11
  from jose import JWTError, jwt
@@ -13,21 +13,21 @@ from jose import JWTError, jwt
13
  # ── Config ────────────────────────────────────────────────────────────────────
14
  SECRET_KEY = os.environ.get("SECRET_KEY", "php-hosting-secret-key-change-me")
15
  ALGORITHM = "HS256"
16
- TOKEN_EXPIRE_MINS = 60 * 24 * 7 # 7 days
17
 
18
- MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB per file
19
- MAX_USER_BYTES = 500 * 1024 * 1024 # 500 MB per user
20
- MAX_TOTAL_BYTES = 15 * 1024 * 1024 * 1024 # 15 GB total
21
 
22
- # ── Storage paths (HF persistent storage) ─────────────────────────────────────
23
  DATA_DIR = Path("/data")
24
- USERS_DIR = DATA_DIR / "users" # /data/users/<username>/<repo>/<files>
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
- # ── Simple JSON database ───────────────────────────────────────────────────────
31
  def load_db() -> dict:
32
  if DB_FILE.exists():
33
  try:
@@ -39,16 +39,26 @@ def load_db() -> dict:
39
  def save_db(db: dict):
40
  DB_FILE.write_text(json.dumps(db, indent=2))
41
 
42
- # ── Security ───────────────────────────────────────────────────────────────────
43
- pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
 
 
 
 
44
 
45
  def hash_password(p: str) -> str:
46
- return pwd_ctx.hash(p)
 
 
 
47
 
48
  def verify_password(plain: str, hashed: str) -> bool:
49
- return pwd_ctx.verify(plain, hashed)
 
 
 
50
 
51
- def create_jwt(data: dict):
52
  to_encode = data.copy()
53
  expire = datetime.utcnow() + timedelta(minutes=TOKEN_EXPIRE_MINS)
54
  to_encode.update({"exp": expire})
@@ -72,7 +82,10 @@ def repo_dir(username: str, repo: str) -> Path:
72
  return p
73
 
74
  def get_dir_size(path: Path) -> int:
75
- return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
 
 
 
76
 
77
  def get_total_storage() -> int:
78
  if USERS_DIR.exists():
@@ -110,6 +123,10 @@ app.add_middleware(
110
  def root():
111
  return {"status": "PHP Hosting Backend is running"}
112
 
 
 
 
 
113
  # ── Register ───────────────────────────────────────────────────────────────────
114
  class RegisterBody(BaseModel):
115
  username: str
@@ -117,34 +134,40 @@ class RegisterBody(BaseModel):
117
 
118
  @app.post("/auth/register")
119
  def register(body: RegisterBody):
120
- if len(body.username) < 3:
121
- raise HTTPException(400, "Username must be at least 3 characters")
122
- if not body.username.isalnum():
123
- raise HTTPException(400, "Username must be letters and numbers only")
124
- if len(body.password) < 8:
125
- raise HTTPException(400, "Password must be at least 8 characters")
126
-
127
- db = load_db()
128
- if body.username in db:
129
- raise HTTPException(400, "Username already taken")
130
-
131
- db[body.username] = {
132
- "username": body.username,
133
- "password_hash": hash_password(body.password),
134
- "created_at": datetime.utcnow().isoformat(),
135
- }
136
- save_db(db)
137
-
138
- # Create user folder immediately
139
- user_dir(body.username)
140
-
141
- token = create_jwt({"sub": body.username})
142
- return {
143
- "access_token": token,
144
- "token_type": "bearer",
145
- "username": body.username,
146
- "message": "Account created successfully!"
147
- }
 
 
 
 
 
 
148
 
149
  # ── Login ──────────────────────────────────────────────────────────────────────
150
  class LoginBody(BaseModel):
@@ -153,16 +176,21 @@ class LoginBody(BaseModel):
153
 
154
  @app.post("/auth/login")
155
  def login(body: LoginBody):
156
- db = load_db()
157
- user = db.get(body.username)
158
- if not user or not verify_password(body.password, user["password_hash"]):
159
- raise HTTPException(401, "Invalid username or password")
160
- token = create_jwt({"sub": body.username})
161
- return {
162
- "access_token": token,
163
- "token_type": "bearer",
164
- "username": body.username,
165
- }
 
 
 
 
 
166
 
167
  # ── Upload files ───────────────────────────────────────────────────────────────
168
  @app.post("/upload")
@@ -171,140 +199,180 @@ async def upload_files(
171
  files: list[UploadFile] = File(...),
172
  user: dict = Depends(get_current_user)
173
  ):
174
- if not repo.isalnum():
175
- raise HTTPException(400, "Repo name must be letters and numbers only")
176
-
177
- # Check total storage
178
- total = get_total_storage()
179
- if total >= MAX_TOTAL_BYTES:
180
- raise HTTPException(507, "Platform storage full. Contact admin.")
181
-
182
- # Check user storage
183
- udir = user_dir(user["username"])
184
- user_used = get_dir_size(udir)
185
- if user_used >= MAX_USER_BYTES:
186
- raise HTTPException(507, "Your storage limit (500 MB) reached.")
187
-
188
- rdir = repo_dir(user["username"], repo)
189
- uploaded = []
190
-
191
- for f in files:
192
- content = await f.read()
193
- if len(content) > MAX_FILE_BYTES:
194
- raise HTTPException(413, f"{f.filename} exceeds 25 MB limit")
195
-
196
- safe_name = Path(f.filename).name
197
- file_path = rdir / safe_name
198
- file_path.write_bytes(content)
199
-
200
- uploaded.append({
201
- "filename": safe_name,
202
- "size": len(content),
203
- "url": f"https://php-hosting.emalawi.workers.dev/{user['username']}/{repo}/{safe_name}"
204
- })
205
-
206
- return {
207
- "uploaded": uploaded,
208
- "repo": repo,
209
- "repo_url": f"https://php-hosting.emalawi.workers.dev/{user['username']}/{repo}"
210
- }
 
211
 
212
  # ── List repos and files ───────────────────────────────────────────────────────
213
  @app.get("/files")
214
  def list_files(user: dict = Depends(get_current_user)):
215
- udir = user_dir(user["username"])
216
- repos = []
217
- for repo_path in sorted(udir.iterdir()):
218
- if repo_path.is_dir():
219
- files = []
220
- for fp in sorted(repo_path.iterdir()):
221
- if fp.is_file():
222
- files.append({
223
- "filename": fp.name,
224
- "size": fp.stat().st_size,
225
- "url": f"https://php-hosting.emalawi.workers.dev/{user['username']}/{repo_path.name}/{fp.name}"
226
- })
227
- repos.append({
228
- "repo": repo_path.name,
229
- "repo_url": f"https://php-hosting.emalawi.workers.dev/{user['username']}/{repo_path.name}",
230
- "files": files
231
- })
232
- used = get_dir_size(udir)
233
- return {
234
- "username": user["username"],
235
- "repos": repos,
236
- "used_mb": round(used / (1024 * 1024), 2),
237
- "limit_mb": 500
238
- }
 
 
 
 
 
239
 
240
  # ── Delete file ────────────────────────────────────────────────────────────────
241
  @app.delete("/files/{repo}/{filename}")
242
  def delete_file(repo: str, filename: str, user: dict = Depends(get_current_user)):
243
- fp = USERS_DIR / user["username"] / repo / filename
244
- if not fp.exists():
245
- raise HTTPException(404, "File not found")
246
- fp.unlink()
247
- return {"message": f"{filename} deleted"}
 
 
 
 
 
248
 
249
  # ── Delete repo ────────────────────────────────────────────────────────────────
250
  @app.delete("/repo/{repo}")
251
  def delete_repo(repo: str, user: dict = Depends(get_current_user)):
252
- rdir = USERS_DIR / user["username"] / repo
253
- if not rdir.exists():
254
- raise HTTPException(404, "Repo not found")
255
- shutil.rmtree(rdir)
256
- return {"message": f"Repo {repo} deleted"}
257
-
258
- # ── Serve PHP / static files (called by Cloudflare Worker) ────────────────────
 
 
 
 
 
259
  @app.get("/serve/{username}/{repo}/{filename:path}")
260
  def serve_file(username: str, repo: str, filename: str):
261
- file_path = USERS_DIR / username / repo / filename
262
- if not file_path.exists():
263
- raise HTTPException(404, "File not found")
264
-
265
- content = file_path.read_bytes()
266
-
267
- if filename.endswith(".php"):
268
- with tempfile.NamedTemporaryFile(suffix=".php", delete=False) as tmp:
269
- tmp.write(content)
270
- tmp_path = tmp.name
271
- try:
272
- result = subprocess.run(
273
- ["php", tmp_path],
274
- capture_output=True, text=True, timeout=30
275
- )
276
- output = result.stdout
277
- if result.returncode != 0:
278
- output = f"<pre style='color:red'>PHP Error:\n{result.stderr}</pre>"
279
- except subprocess.TimeoutExpired:
280
- output = "<pre style='color:red'>Error: Script timed out (30s limit)</pre>"
281
- finally:
282
- os.unlink(tmp_path)
283
- return HTMLResponse(content=output)
284
-
285
- # Serve static files
286
- ext = filename.split(".")[-1].lower()
287
- mime_map = {
288
- "html": "text/html", "css": "text/css",
289
- "js": "application/javascript", "json": "application/json",
290
- "png": "image/png", "jpg": "image/jpeg",
291
- "gif": "image/gif", "svg": "image/svg+xml",
292
- "txt": "text/plain"
293
- }
294
- mime = mime_map.get(ext, "application/octet-stream")
295
- from fastapi.responses import Response
296
- return Response(content=content, media_type=mime)
297
-
298
- # ── Storage stats (admin) ──────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  @app.get("/storage")
300
  def storage_stats(user: dict = Depends(get_current_user)):
301
- udir = user_dir(user["username"])
302
- user_used = get_dir_size(udir)
303
- total_used = get_total_storage()
304
- return {
305
- "user_used_mb": round(user_used / (1024 * 1024), 2),
306
- "user_limit_mb": 500,
307
- "total_used_gb": round(total_used / (1024 ** 3), 3),
308
- "total_limit_gb": 15,
309
- "percent_used": round((user_used / MAX_USER_BYTES) * 100, 2)
310
- }
 
 
 
 
 
 
5
 
6
  from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Form, Request
7
  from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse, Response
9
  from pydantic import BaseModel
10
  from passlib.context import CryptContext
11
  from jose import JWTError, jwt
 
13
  # ── Config ────────────────────────────────────────────────────────────────────
14
  SECRET_KEY = os.environ.get("SECRET_KEY", "php-hosting-secret-key-change-me")
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:
 
39
  def save_db(db: dict):
40
  DB_FILE.write_text(json.dumps(db, indent=2))
41
 
42
+ # ── Security ──────────────────────────────────────────────────────────────────
43
+ pwd_ctx = CryptContext(
44
+ schemes=["bcrypt"],
45
+ deprecated="auto",
46
+ bcrypt__rounds=10
47
+ )
48
 
49
  def hash_password(p: str) -> str:
50
+ try:
51
+ return pwd_ctx.hash(p)
52
+ except Exception as e:
53
+ raise HTTPException(500, f"Password hashing error: {e}")
54
 
55
  def verify_password(plain: str, hashed: str) -> bool:
56
+ try:
57
+ return pwd_ctx.verify(plain, hashed)
58
+ except:
59
+ return False
60
 
61
+ def create_jwt(data: dict) -> str:
62
  to_encode = data.copy()
63
  expire = datetime.utcnow() + timedelta(minutes=TOKEN_EXPIRE_MINS)
64
  to_encode.update({"exp": expire})
 
82
  return p
83
 
84
  def get_dir_size(path: Path) -> int:
85
+ try:
86
+ return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
87
+ except:
88
+ return 0
89
 
90
  def get_total_storage() -> int:
91
  if USERS_DIR.exists():
 
123
  def root():
124
  return {"status": "PHP Hosting Backend is running"}
125
 
126
+ @app.get("/health")
127
+ def health():
128
+ return {"status": "ok", "users": len(load_db())}
129
+
130
  # ── Register ───────────────────────────────────────────────────────────────────
131
  class RegisterBody(BaseModel):
132
  username: str
 
134
 
135
  @app.post("/auth/register")
136
  def register(body: RegisterBody):
137
+ try:
138
+ if len(body.username) < 3:
139
+ raise HTTPException(400, "Username must be at least 3 characters")
140
+ if not body.username.isalnum():
141
+ raise HTTPException(400, "Username must be letters and numbers only")
142
+ if len(body.password) < 8:
143
+ raise HTTPException(400, "Password must be at least 8 characters")
144
+
145
+ db = load_db()
146
+
147
+ if body.username in db:
148
+ raise HTTPException(400, "Username already taken")
149
+
150
+ hashed = hash_password(body.password)
151
+
152
+ db[body.username] = {
153
+ "username": body.username,
154
+ "password_hash": hashed,
155
+ "created_at": datetime.utcnow().isoformat(),
156
+ }
157
+ save_db(db)
158
+ user_dir(body.username)
159
+
160
+ token = create_jwt({"sub": body.username})
161
+ return {
162
+ "access_token": token,
163
+ "token_type": "bearer",
164
+ "username": body.username,
165
+ "message": "Account created successfully!"
166
+ }
167
+ except HTTPException:
168
+ raise
169
+ except Exception as e:
170
+ raise HTTPException(500, str(e))
171
 
172
  # ── Login ──────────────────────────────────────────────────────────────────────
173
  class LoginBody(BaseModel):
 
176
 
177
  @app.post("/auth/login")
178
  def login(body: LoginBody):
179
+ try:
180
+ db = load_db()
181
+ user = db.get(body.username)
182
+ if not user or not verify_password(body.password, user["password_hash"]):
183
+ raise HTTPException(401, "Invalid username or password")
184
+ token = create_jwt({"sub": body.username})
185
+ return {
186
+ "access_token": token,
187
+ "token_type": "bearer",
188
+ "username": body.username,
189
+ }
190
+ except HTTPException:
191
+ raise
192
+ except Exception as e:
193
+ raise HTTPException(500, str(e))
194
 
195
  # ── Upload files ───────────────────────────────────────────────────────────────
196
  @app.post("/upload")
 
199
  files: list[UploadFile] = File(...),
200
  user: dict = Depends(get_current_user)
201
  ):
202
+ try:
203
+ if not repo.isalnum():
204
+ raise HTTPException(400, "Repo name must be letters and numbers only")
205
+
206
+ total = get_total_storage()
207
+ if total >= MAX_TOTAL_BYTES:
208
+ raise HTTPException(507, "Platform storage full. Contact admin.")
209
+
210
+ udir = user_dir(user["username"])
211
+ user_used = get_dir_size(udir)
212
+ if user_used >= MAX_USER_BYTES:
213
+ raise HTTPException(507, "Your storage limit (500 MB) reached.")
214
+
215
+ rdir = repo_dir(user["username"], repo)
216
+ uploaded = []
217
+
218
+ for f in files:
219
+ content = await f.read()
220
+ if len(content) > MAX_FILE_BYTES:
221
+ raise HTTPException(413, f"{f.filename} exceeds 25 MB limit")
222
+ safe_name = Path(f.filename).name
223
+ file_path = rdir / safe_name
224
+ file_path.write_bytes(content)
225
+ uploaded.append({
226
+ "filename": safe_name,
227
+ "size": len(content),
228
+ "url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo}/{safe_name}"
229
+ })
230
+
231
+ return {
232
+ "uploaded": uploaded,
233
+ "repo": repo,
234
+ "repo_url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo}"
235
+ }
236
+ except HTTPException:
237
+ raise
238
+ except Exception as e:
239
+ raise HTTPException(500, str(e))
240
 
241
  # ── List repos and files ───────────────────────────────────────────────────────
242
  @app.get("/files")
243
  def list_files(user: dict = Depends(get_current_user)):
244
+ try:
245
+ udir = user_dir(user["username"])
246
+ repos = []
247
+ for repo_path in sorted(udir.iterdir()):
248
+ if repo_path.is_dir():
249
+ files = []
250
+ for fp in sorted(repo_path.iterdir()):
251
+ if fp.is_file():
252
+ files.append({
253
+ "filename": fp.name,
254
+ "size": fp.stat().st_size,
255
+ "url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo_path.name}/{fp.name}"
256
+ })
257
+ repos.append({
258
+ "repo": repo_path.name,
259
+ "repo_url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo_path.name}",
260
+ "files": files
261
+ })
262
+ used = get_dir_size(udir)
263
+ return {
264
+ "username": user["username"],
265
+ "repos": repos,
266
+ "used_mb": round(used / (1024 * 1024), 2),
267
+ "limit_mb": 500
268
+ }
269
+ except HTTPException:
270
+ raise
271
+ except Exception as e:
272
+ raise HTTPException(500, str(e))
273
 
274
  # ── Delete file ────────────────────────────────────────────────────────────────
275
  @app.delete("/files/{repo}/{filename}")
276
  def delete_file(repo: str, filename: str, user: dict = Depends(get_current_user)):
277
+ try:
278
+ fp = USERS_DIR / user["username"] / repo / filename
279
+ if not fp.exists():
280
+ raise HTTPException(404, "File not found")
281
+ fp.unlink()
282
+ return {"message": f"{filename} deleted"}
283
+ except HTTPException:
284
+ raise
285
+ except Exception as e:
286
+ raise HTTPException(500, str(e))
287
 
288
  # ── Delete repo ────────────────────────────────────────────────────────────────
289
  @app.delete("/repo/{repo}")
290
  def delete_repo(repo: str, user: dict = Depends(get_current_user)):
291
+ try:
292
+ rdir = USERS_DIR / user["username"] / repo
293
+ if not rdir.exists():
294
+ raise HTTPException(404, "Repo not found")
295
+ shutil.rmtree(rdir)
296
+ return {"message": f"Repo {repo} deleted"}
297
+ except HTTPException:
298
+ raise
299
+ except Exception as e:
300
+ raise HTTPException(500, str(e))
301
+
302
+ # ── Serve PHP / static files ───────────────────────────────────────────────────
303
  @app.get("/serve/{username}/{repo}/{filename:path}")
304
  def serve_file(username: str, repo: str, filename: str):
305
+ try:
306
+ file_path = USERS_DIR / username / repo / filename
307
+ if not file_path.exists():
308
+ raise HTTPException(404, "File not found")
309
+
310
+ content = file_path.read_bytes()
311
+
312
+ if filename.endswith(".php"):
313
+ with tempfile.NamedTemporaryFile(
314
+ suffix=".php", delete=False, dir="/tmp"
315
+ ) as tmp:
316
+ tmp.write(content)
317
+ tmp_path = tmp.name
318
+ try:
319
+ result = subprocess.run(
320
+ ["php", tmp_path],
321
+ capture_output=True,
322
+ text=True,
323
+ timeout=30
324
+ )
325
+ output = result.stdout
326
+ if result.returncode != 0:
327
+ output = f"<pre style='color:red;font-family:monospace;padding:20px'>PHP Error:\n{result.stderr}</pre>"
328
+ except subprocess.TimeoutExpired:
329
+ output = "<pre style='color:red'>Error: Script timed out (30s limit)</pre>"
330
+ except FileNotFoundError:
331
+ output = "<pre style='color:red'>Error: PHP not installed on server</pre>"
332
+ finally:
333
+ try:
334
+ os.unlink(tmp_path)
335
+ except:
336
+ pass
337
+ return HTMLResponse(content=output)
338
+
339
+ ext = filename.split(".")[-1].lower()
340
+ mime_map = {
341
+ "html": "text/html",
342
+ "css": "text/css",
343
+ "js": "application/javascript",
344
+ "json": "application/json",
345
+ "png": "image/png",
346
+ "jpg": "image/jpeg",
347
+ "jpeg": "image/jpeg",
348
+ "gif": "image/gif",
349
+ "svg": "image/svg+xml",
350
+ "txt": "text/plain",
351
+ "ico": "image/x-icon",
352
+ }
353
+ mime = mime_map.get(ext, "application/octet-stream")
354
+ return Response(content=content, media_type=mime)
355
+
356
+ except HTTPException:
357
+ raise
358
+ except Exception as e:
359
+ raise HTTPException(500, str(e))
360
+
361
+ # ── Storage stats ──────────────────────────────────────────────────────────────
362
  @app.get("/storage")
363
  def storage_stats(user: dict = Depends(get_current_user)):
364
+ try:
365
+ udir = user_dir(user["username"])
366
+ user_used = get_dir_size(udir)
367
+ total_used = get_total_storage()
368
+ return {
369
+ "user_used_mb": round(user_used / (1024 * 1024), 2),
370
+ "user_limit_mb": 500,
371
+ "total_used_gb": round(total_used / (1024 ** 3), 3),
372
+ "total_limit_gb": 15,
373
+ "percent_used": round((user_used / MAX_USER_BYTES) * 100, 2)
374
+ }
375
+ except HTTPException:
376
+ raise
377
+ except Exception as e:
378
+ raise HTTPException(500, str(e))