Emalawi19 commited on
Commit
0f8a210
Β·
verified Β·
1 Parent(s): 568816c

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +310 -0
app.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, io, json, subprocess, tempfile, secrets, shutil
2
+ from datetime import datetime, timedelta
3
+ from typing import Optional
4
+ 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
12
+
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:
34
+ return json.loads(DB_FILE.read_text())
35
+ except:
36
+ return {}
37
+ return {}
38
+
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})
55
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
56
+
57
+ def decode_jwt(token: str):
58
+ try:
59
+ return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
60
+ except JWTError:
61
+ return None
62
+
63
+ # ── Storage helpers ────────────────────────────────────────────────────────────
64
+ def user_dir(username: str) -> Path:
65
+ p = USERS_DIR / username
66
+ p.mkdir(parents=True, exist_ok=True)
67
+ return p
68
+
69
+ def repo_dir(username: str, repo: str) -> Path:
70
+ p = user_dir(username) / repo
71
+ p.mkdir(parents=True, exist_ok=True)
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():
79
+ return get_dir_size(USERS_DIR)
80
+ return 0
81
+
82
+ # ── Auth dependency ────────────────────────────────────────────────────────────
83
+ def get_current_user(request: Request) -> dict:
84
+ auth = request.headers.get("Authorization", "")
85
+ if not auth.startswith("Bearer "):
86
+ raise HTTPException(401, "Missing token")
87
+ payload = decode_jwt(auth.split(" ")[1])
88
+ if not payload:
89
+ raise HTTPException(401, "Invalid or expired token")
90
+ username = payload.get("sub")
91
+ db = load_db()
92
+ if username not in db:
93
+ raise HTTPException(401, "User not found")
94
+ return db[username]
95
+
96
+ # ── App ────────────────────────────────────────────────────────────────────────
97
+ app = FastAPI(title="PHP Hosting Backend")
98
+
99
+ app.add_middleware(
100
+ CORSMiddleware,
101
+ allow_origins=["*"],
102
+ allow_credentials=True,
103
+ allow_methods=["*"],
104
+ allow_headers=["*"],
105
+ )
106
+
107
+ # ── Routes ─────────────────────────────────────────────────────────────────────
108
+
109
+ @app.get("/")
110
+ def root():
111
+ return {"status": "PHP Hosting Backend is running"}
112
+
113
+ # ── Register ─────────────────────────────────────────────────────────────────��─
114
+ class RegisterBody(BaseModel):
115
+ username: str
116
+ password: str
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):
151
+ username: str
152
+ password: str
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")
169
+ async def upload_files(
170
+ repo: str = Form(...),
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
+ }