Spaces:
Sleeping
Sleeping
| import os, json, subprocess, tempfile, shutil, base64, sqlite3, io | |
| import bcrypt | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| from typing import Optional, List | |
| import httpx | |
| from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Form, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import HTMLResponse, Response, StreamingResponse | |
| from pydantic import BaseModel | |
| from jose import JWTError, jwt | |
| # ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| SECRET_KEY = os.environ.get("SECRET_KEY", "php-hosting-secret-key-change-me") | |
| ALGORITHM = "HS256" | |
| TOKEN_EXPIRE_MINS = 60 * 24 * 7 | |
| MAX_FILE_BYTES = 25 * 1024 * 1024 | |
| # ββ GitHub Config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") | |
| GITHUB_USERNAME = os.environ.get("GITHUB_USERNAME") | |
| GITHUB_REPO = "Master" | |
| GITHUB_BASE = "PHP/Users" | |
| GITHUB_API = "https://api.github.com" | |
| # ββ Local storage βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| DATA_DIR = Path("/data") | |
| DB_FILE = DATA_DIR / "db" / "users.json" | |
| DBS_DIR = DATA_DIR / "databases" | |
| DB_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| DBS_DIR.mkdir(parents=True, exist_ok=True) | |
| # ββ User DB (JSON) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_db() -> dict: | |
| if DB_FILE.exists(): | |
| try: | |
| return json.loads(DB_FILE.read_text()) | |
| except: | |
| return {} | |
| return {} | |
| def save_db(db: dict): | |
| DB_FILE.write_text(json.dumps(db, indent=2)) | |
| # ββ Security ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def hash_password(p: str) -> str: | |
| try: | |
| pwd_bytes = p.encode("utf-8")[:72] | |
| salt = bcrypt.gensalt(rounds=10) | |
| return bcrypt.hashpw(pwd_bytes, salt).decode("utf-8") | |
| except Exception as e: | |
| raise HTTPException(500, f"Password hashing error: {e}") | |
| def verify_password(plain: str, hashed: str) -> bool: | |
| try: | |
| return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("utf-8")) | |
| except: | |
| return False | |
| def create_jwt(data: dict) -> str: | |
| to_encode = data.copy() | |
| expire = datetime.utcnow() + timedelta(minutes=TOKEN_EXPIRE_MINS) | |
| to_encode.update({"exp": expire}) | |
| return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) | |
| def decode_jwt(token: str): | |
| try: | |
| return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) | |
| except JWTError: | |
| return None | |
| # ββ GitHub helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def gh_headers() -> dict: | |
| if not GITHUB_TOKEN: | |
| raise HTTPException(500, "GitHub token not configured") | |
| return { | |
| "Authorization": f"token {GITHUB_TOKEN}", | |
| "Accept": "application/vnd.github+json", | |
| "X-GitHub-Api-Version": "2022-11-28", | |
| } | |
| def gh_path(username: str, repo: str = None, filename: str = None) -> str: | |
| path = f"{GITHUB_BASE}/{username}" | |
| if repo: | |
| path += f"/{repo}" | |
| if filename: | |
| path += f"/{filename}" | |
| return path | |
| def gh_db_path(username: str, dbname: str) -> str: | |
| return f"{GITHUB_BASE}/{username}/_databases/{dbname}.db" | |
| async def gh_get_file(path: str) -> Optional[dict]: | |
| url = f"{GITHUB_API}/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/contents/{path}" | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| res = await client.get(url, headers=gh_headers()) | |
| if res.status_code == 404: | |
| return None | |
| if res.status_code != 200: | |
| raise HTTPException(res.status_code, f"GitHub error: {res.text}") | |
| return res.json() | |
| async def gh_put_file(path: str, content_bytes: bytes, message: str, sha: str = None): | |
| url = f"{GITHUB_API}/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/contents/{path}" | |
| body = { | |
| "message": message, | |
| "content": base64.b64encode(content_bytes).decode("utf-8"), | |
| } | |
| if sha: | |
| body["sha"] = sha | |
| async with httpx.AsyncClient(timeout=60) as client: | |
| res = await client.put(url, headers=gh_headers(), json=body) | |
| if res.status_code not in (200, 201): | |
| raise HTTPException(res.status_code, f"GitHub upload error: {res.text}") | |
| return res.json() | |
| async def gh_delete_file(path: str, sha: str, message: str): | |
| url = f"{GITHUB_API}/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/contents/{path}" | |
| body = {"message": message, "sha": sha} | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| res = await client.delete(url, headers=gh_headers(), json=body) | |
| if res.status_code not in (200, 204): | |
| raise HTTPException(res.status_code, f"GitHub delete error: {res.text}") | |
| async def gh_list_folder(path: str) -> list: | |
| url = f"{GITHUB_API}/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/contents/{path}" | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| res = await client.get(url, headers=gh_headers()) | |
| if res.status_code == 404: | |
| return [] | |
| if res.status_code != 200: | |
| return [] | |
| return res.json() | |
| async def gh_create_placeholder(path: str, username: str): | |
| placeholder_path = f"{path}/.gitkeep" | |
| existing = await gh_get_file(placeholder_path) | |
| if not existing: | |
| await gh_put_file(placeholder_path, b"", f"Create folder for {username}") | |
| async def gh_ensure_repo_exists(): | |
| url = f"{GITHUB_API}/repos/{GITHUB_USERNAME}/{GITHUB_REPO}" | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| res = await client.get(url, headers=gh_headers()) | |
| if res.status_code == 404: | |
| create_url = f"{GITHUB_API}/user/repos" | |
| await client.post(create_url, headers=gh_headers(), json={ | |
| "name": GITHUB_REPO, | |
| "private": True, | |
| "description": "PHP Hosting Storage" | |
| }) | |
| # ββ SQLite database helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_user_db_dir(username: str) -> Path: | |
| p = DBS_DIR / username | |
| p.mkdir(parents=True, exist_ok=True) | |
| return p | |
| def get_db_path(username: str, dbname: str) -> Path: | |
| return get_user_db_dir(username) / f"{dbname}.db" | |
| def get_conn(username: str, dbname: str) -> sqlite3.Connection: | |
| db_path = get_db_path(username, dbname) | |
| if not db_path.exists(): | |
| raise HTTPException(404, f"Database '{dbname}' not found") | |
| conn = sqlite3.connect(str(db_path)) | |
| conn.row_factory = sqlite3.Row | |
| return conn | |
| async def backup_db_to_github(username: str, dbname: str): | |
| db_path = get_db_path(username, dbname) | |
| if not db_path.exists(): | |
| return | |
| content = db_path.read_bytes() | |
| gh_path_str = gh_db_path(username, dbname) | |
| existing = await gh_get_file(gh_path_str) | |
| sha = existing["sha"] if existing else None | |
| await gh_put_file( | |
| gh_path_str, | |
| content, | |
| f"Backup database {dbname} for {username}", | |
| sha | |
| ) | |
| def list_user_databases(username: str) -> list: | |
| db_dir = get_user_db_dir(username) | |
| dbs = [] | |
| for f in sorted(db_dir.glob("*.db")): | |
| conn = sqlite3.connect(str(f)) | |
| cursor = conn.cursor() | |
| cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") | |
| tables = [row[0] for row in cursor.fetchall()] | |
| conn.close() | |
| size = f.stat().st_size | |
| dbs.append({ | |
| "name": f.stem, | |
| "tables": tables, | |
| "table_count": len(tables), | |
| "size_kb": round(size / 1024, 2), | |
| "created_at": datetime.fromtimestamp(f.stat().st_ctime).isoformat(), | |
| }) | |
| return dbs | |
| # ββ Auth dependency ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_current_user(request: Request) -> dict: | |
| auth = request.headers.get("Authorization", "") | |
| if not auth.startswith("Bearer "): | |
| raise HTTPException(401, "Missing token") | |
| payload = decode_jwt(auth.split(" ")[1]) | |
| if not payload: | |
| raise HTTPException(401, "Invalid or expired token") | |
| username = payload.get("sub") | |
| db = load_db() | |
| if username not in db: | |
| raise HTTPException(401, "User not found") | |
| return db[username] | |
| # ββ App ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI(title="PHP Hosting Backend") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ββ Health βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def root(): | |
| return {"status": "PHP Hosting Backend is running", "storage": "GitHub + SQLite"} | |
| def health(): | |
| return {"status": "ok", "users": len(load_db())} | |
| # ββ Register βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class RegisterBody(BaseModel): | |
| username: str | |
| password: str | |
| async def register(body: RegisterBody): | |
| try: | |
| if len(body.username) < 3: | |
| raise HTTPException(400, "Username must be at least 3 characters") | |
| if not body.username.isalnum(): | |
| raise HTTPException(400, "Username must be letters and numbers only") | |
| if len(body.password) < 8: | |
| raise HTTPException(400, "Password must be at least 8 characters") | |
| db = load_db() | |
| if body.username in db: | |
| raise HTTPException(400, "Username already taken") | |
| hashed = hash_password(body.password) | |
| await gh_ensure_repo_exists() | |
| user_folder = gh_path(body.username) | |
| await gh_create_placeholder(user_folder, body.username) | |
| db[body.username] = { | |
| "username": body.username, | |
| "password_hash": hashed, | |
| "created_at": datetime.utcnow().isoformat(), | |
| "github_path": user_folder, | |
| } | |
| save_db(db) | |
| get_user_db_dir(body.username) | |
| token = create_jwt({"sub": body.username}) | |
| return { | |
| "access_token": token, | |
| "token_type": "bearer", | |
| "username": body.username, | |
| "message": "Account created successfully!" | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Login ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class LoginBody(BaseModel): | |
| username: str | |
| password: str | |
| def login(body: LoginBody): | |
| try: | |
| db = load_db() | |
| user = db.get(body.username) | |
| if not user or not verify_password(body.password, user["password_hash"]): | |
| raise HTTPException(401, "Invalid username or password") | |
| token = create_jwt({"sub": body.username}) | |
| return { | |
| "access_token": token, | |
| "token_type": "bearer", | |
| "username": body.username, | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Upload files βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def upload_files( | |
| repo: str = Form(...), | |
| visibility: str = Form("public"), | |
| files: list[UploadFile] = File(...), | |
| user: dict = Depends(get_current_user) | |
| ): | |
| try: | |
| if not repo.isalnum(): | |
| raise HTTPException(400, "Repo name must be letters and numbers only") | |
| uploaded = [] | |
| for f in files: | |
| content = await f.read() | |
| if len(content) > MAX_FILE_BYTES: | |
| raise HTTPException(413, f"{f.filename} exceeds 25 MB limit") | |
| safe_name = Path(f.filename).name | |
| file_path = gh_path(user["username"], repo, safe_name) | |
| existing = await gh_get_file(file_path) | |
| sha = existing["sha"] if existing else None | |
| await gh_put_file( | |
| file_path, content, | |
| f"Upload {safe_name} to {user['username']}/{repo}", sha | |
| ) | |
| uploaded.append({ | |
| "filename": safe_name, | |
| "size": len(content), | |
| "url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo}/{safe_name}" | |
| }) | |
| # Save visibility metadata | |
| meta_path = gh_path(user["username"], repo, ".meta.json") | |
| meta_existing = await gh_get_file(meta_path) | |
| meta_sha = meta_existing["sha"] if meta_existing else None | |
| meta = {"visibility": visibility, "updated_at": datetime.utcnow().isoformat()} | |
| await gh_put_file( | |
| meta_path, | |
| json.dumps(meta).encode(), | |
| f"Update metadata for {user['username']}/{repo}", | |
| meta_sha | |
| ) | |
| return { | |
| "uploaded": uploaded, | |
| "repo": repo, | |
| "visibility": visibility, | |
| "repo_url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo}" | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ List repos and files βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def list_files(user: dict = Depends(get_current_user)): | |
| try: | |
| user_folder = gh_path(user["username"]) | |
| items = await gh_list_folder(user_folder) | |
| repos = [] | |
| total_size = 0 | |
| for item in items: | |
| if item.get("type") == "dir": | |
| repo_name = item["name"] | |
| if repo_name.startswith("_"): | |
| continue | |
| repo_items = await gh_list_folder(f"{user_folder}/{repo_name}") | |
| files = [] | |
| visibility = "public" | |
| for fi in repo_items: | |
| if fi.get("type") == "file": | |
| if fi["name"] == ".meta.json": | |
| try: | |
| meta_info = await gh_get_file(f"{user_folder}/{repo_name}/.meta.json") | |
| if meta_info: | |
| meta = json.loads(base64.b64decode(meta_info["content"].replace("\n","")).decode()) | |
| visibility = meta.get("visibility","public") | |
| except: | |
| pass | |
| continue | |
| if fi["name"] == ".gitkeep": | |
| continue | |
| size = fi.get("size", 0) | |
| total_size += size | |
| files.append({ | |
| "filename": fi["name"], | |
| "size": size, | |
| "sha": fi["sha"], | |
| "url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo_name}/{fi['name']}" | |
| }) | |
| repos.append({ | |
| "repo": repo_name, | |
| "visibility": visibility, | |
| "repo_url": f"https://php-hosting.emalawi19.workers.dev/{user['username']}/{repo_name}", | |
| "files": files | |
| }) | |
| return { | |
| "username": user["username"], | |
| "repos": repos, | |
| "used_mb": round(total_size / (1024 * 1024), 2), | |
| "limit_mb": 500 | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Delete file ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def delete_file(repo: str, filename: str, user: dict = Depends(get_current_user)): | |
| try: | |
| file_path = gh_path(user["username"], repo, filename) | |
| existing = await gh_get_file(file_path) | |
| if not existing: | |
| raise HTTPException(404, "File not found") | |
| await gh_delete_file(file_path, existing["sha"], f"Delete {filename} from {user['username']}/{repo}") | |
| return {"message": f"{filename} deleted"} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Delete repo ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def delete_repo(repo: str, user: dict = Depends(get_current_user)): | |
| try: | |
| repo_folder = gh_path(user["username"], repo) | |
| items = await gh_list_folder(repo_folder) | |
| for item in items: | |
| if item.get("type") == "file": | |
| await gh_delete_file( | |
| f"{repo_folder}/{item['name']}", | |
| item["sha"], | |
| f"Delete {item['name']} from {user['username']}/{repo}" | |
| ) | |
| return {"message": f"Repo {repo} deleted"} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Serve PHP / static files βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def serve_file(username: str, repo: str, filename: str): | |
| try: | |
| file_path = gh_path(username, repo, filename) | |
| file_info = await gh_get_file(file_path) | |
| if not file_info: | |
| if filename == "index.php": | |
| file_info = await gh_get_file(gh_path(username, repo, "index.html")) | |
| if file_info: | |
| filename = "index.html" | |
| if not file_info: | |
| raise HTTPException(404, "File not found") | |
| content = base64.b64decode(file_info["content"].replace("\n", "")) | |
| if filename.endswith(".php"): | |
| with tempfile.NamedTemporaryFile(suffix=".php", delete=False, dir="/tmp") as tmp: | |
| tmp.write(content) | |
| tmp_path = tmp.name | |
| try: | |
| result = subprocess.run( | |
| ["php", tmp_path], | |
| capture_output=True, text=True, timeout=30 | |
| ) | |
| output = result.stdout | |
| if result.returncode != 0: | |
| output = f"<pre style='color:red;padding:20px'>PHP Error:\n{result.stderr}</pre>" | |
| except subprocess.TimeoutExpired: | |
| output = "<pre style='color:red'>Error: Script timed out</pre>" | |
| except FileNotFoundError: | |
| output = "<pre style='color:red'>Error: PHP not installed</pre>" | |
| finally: | |
| try: | |
| os.unlink(tmp_path) | |
| except: | |
| pass | |
| return HTMLResponse(content=output) | |
| ext = filename.split(".")[-1].lower() | |
| mime_map = { | |
| "html": "text/html", "css": "text/css", | |
| "js": "application/javascript", "json": "application/json", | |
| "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", | |
| "gif": "image/gif", "svg": "image/svg+xml", | |
| "txt": "text/plain", "ico": "image/x-icon", | |
| } | |
| mime = mime_map.get(ext, "application/octet-stream") | |
| return Response(content=content, media_type=mime) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ββ DATABASE ROUTES βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ββ List user databases ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def list_databases(user: dict = Depends(get_current_user)): | |
| try: | |
| dbs = list_user_databases(user["username"]) | |
| return {"databases": dbs, "count": len(dbs)} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Create database ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class CreateDBBody(BaseModel): | |
| name: str | |
| async def create_database(body: CreateDBBody, user: dict = Depends(get_current_user)): | |
| try: | |
| if not body.name.replace("_","").isalnum(): | |
| raise HTTPException(400, "Database name must be letters, numbers, underscores only") | |
| if len(body.name) < 2: | |
| raise HTTPException(400, "Database name must be at least 2 characters") | |
| db_path = get_db_path(user["username"], body.name) | |
| if db_path.exists(): | |
| raise HTTPException(400, f"Database '{body.name}' already exists") | |
| conn = sqlite3.connect(str(db_path)) | |
| conn.execute("PRAGMA journal_mode=WAL") | |
| conn.execute("PRAGMA foreign_keys=ON") | |
| conn.close() | |
| await backup_db_to_github(user["username"], body.name) | |
| return { | |
| "message": f"Database '{body.name}' created successfully", | |
| "name": body.name, | |
| "php_code": generate_php_connection(user["username"], body.name) | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Drop database ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def drop_database(dbname: str, user: dict = Depends(get_current_user)): | |
| try: | |
| db_path = get_db_path(user["username"], dbname) | |
| if not db_path.exists(): | |
| raise HTTPException(404, f"Database '{dbname}' not found") | |
| db_path.unlink() | |
| gh_path_str = gh_db_path(user["username"], dbname) | |
| existing = await gh_get_file(gh_path_str) | |
| if existing: | |
| await gh_delete_file(gh_path_str, existing["sha"], f"Drop database {dbname}") | |
| return {"message": f"Database '{dbname}' deleted"} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ List tables ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def list_tables(dbname: str, user: dict = Depends(get_current_user)): | |
| try: | |
| conn = get_conn(user["username"], dbname) | |
| cursor = conn.cursor() | |
| cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") | |
| tables = [] | |
| for row in cursor.fetchall(): | |
| tname = row[0] | |
| cursor.execute(f"PRAGMA table_info({tname})") | |
| cols = [{"name": c[1], "type": c[2], "notnull": bool(c[3]), "pk": bool(c[5])} for c in cursor.fetchall()] | |
| cursor.execute(f"SELECT COUNT(*) FROM `{tname}`") | |
| count = cursor.fetchone()[0] | |
| tables.append({"name": tname, "columns": cols, "row_count": count}) | |
| conn.close() | |
| return {"tables": tables} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Create table βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class ColumnDef(BaseModel): | |
| name: str | |
| type: str | |
| primary_key: bool = False | |
| not_null: bool = False | |
| default: Optional[str] = None | |
| auto_increment: bool = False | |
| class CreateTableBody(BaseModel): | |
| table_name: str | |
| columns: List[ColumnDef] | |
| async def create_table(dbname: str, body: CreateTableBody, user: dict = Depends(get_current_user)): | |
| try: | |
| if not body.table_name.replace("_","").isalnum(): | |
| raise HTTPException(400, "Table name must be letters, numbers, underscores only") | |
| col_defs = [] | |
| for col in body.columns: | |
| col_sql = f"`{col.name}` {col.type}" | |
| if col.primary_key: | |
| col_sql += " PRIMARY KEY" | |
| if col.auto_increment: | |
| col_sql += " AUTOINCREMENT" | |
| if col.not_null and not col.primary_key: | |
| col_sql += " NOT NULL" | |
| if col.default is not None: | |
| col_sql += f" DEFAULT {col.default}" | |
| col_defs.append(col_sql) | |
| sql = f"CREATE TABLE IF NOT EXISTS `{body.table_name}` ({', '.join(col_defs)})" | |
| conn = get_conn(user["username"], dbname) | |
| conn.execute(sql) | |
| conn.commit() | |
| conn.close() | |
| await backup_db_to_github(user["username"], dbname) | |
| return {"message": f"Table '{body.table_name}' created", "sql": sql} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Drop table βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def drop_table(dbname: str, table_name: str, user: dict = Depends(get_current_user)): | |
| try: | |
| conn = get_conn(user["username"], dbname) | |
| conn.execute(f"DROP TABLE IF EXISTS `{table_name}`") | |
| conn.commit() | |
| conn.close() | |
| await backup_db_to_github(user["username"], dbname) | |
| return {"message": f"Table '{table_name}' dropped"} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Get table data βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_table_data( | |
| dbname: str, | |
| table_name: str, | |
| limit: int = 100, | |
| offset: int = 0, | |
| user: dict = Depends(get_current_user) | |
| ): | |
| try: | |
| conn = get_conn(user["username"], dbname) | |
| cursor = conn.cursor() | |
| cursor.execute(f"SELECT COUNT(*) FROM `{table_name}`") | |
| total = cursor.fetchone()[0] | |
| cursor.execute(f"SELECT * FROM `{table_name}` LIMIT ? OFFSET ?", (limit, offset)) | |
| rows = cursor.fetchall() | |
| cols = [d[0] for d in cursor.description] | |
| data = [dict(zip(cols, row)) for row in rows] | |
| conn.close() | |
| return {"columns": cols, "rows": data, "total": total, "limit": limit, "offset": offset} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Run SQL query ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class RunSQLBody(BaseModel): | |
| sql: str | |
| async def run_query(dbname: str, body: RunSQLBody, user: dict = Depends(get_current_user)): | |
| try: | |
| sql = body.sql.strip() | |
| if not sql: | |
| raise HTTPException(400, "SQL query cannot be empty") | |
| # Block dangerous operations | |
| sql_upper = sql.upper() | |
| blocked = ["DROP DATABASE", "ATTACH", "DETACH", "PRAGMA"] | |
| for b in blocked: | |
| if b in sql_upper: | |
| raise HTTPException(400, f"Operation '{b}' is not allowed") | |
| conn = get_conn(user["username"], dbname) | |
| cursor = conn.cursor() | |
| try: | |
| cursor.executescript(sql) if ";" in sql and sql.count(";") > 1 else cursor.execute(sql) | |
| is_select = sql_upper.startswith("SELECT") or sql_upper.startswith("PRAGMA") | |
| if is_select: | |
| rows = cursor.fetchall() | |
| cols = [d[0] for d in cursor.description] if cursor.description else [] | |
| data = [dict(zip(cols, row)) for row in rows] | |
| conn.close() | |
| return { | |
| "type": "select", | |
| "columns": cols, | |
| "rows": data, | |
| "count": len(data) | |
| } | |
| else: | |
| conn.commit() | |
| affected = cursor.rowcount | |
| conn.close() | |
| await backup_db_to_github(user["username"], dbname) | |
| return { | |
| "type": "modify", | |
| "message": "Query executed successfully", | |
| "affected": affected | |
| } | |
| except sqlite3.Error as e: | |
| conn.close() | |
| raise HTTPException(400, f"SQL Error: {str(e)}") | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Insert row βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def insert_row( | |
| dbname: str, | |
| table_name: str, | |
| request: Request, | |
| user: dict = Depends(get_current_user) | |
| ): | |
| try: | |
| row_data = await request.json() | |
| if not row_data: | |
| raise HTTPException(400, "Row data cannot be empty") | |
| cols = ", ".join([f"`{k}`" for k in row_data.keys()]) | |
| placeholders = ", ".join(["?" for _ in row_data]) | |
| values = list(row_data.values()) | |
| sql = f"INSERT INTO `{table_name}` ({cols}) VALUES ({placeholders})" | |
| conn = get_conn(user["username"], dbname) | |
| cursor = conn.cursor() | |
| cursor.execute(sql, values) | |
| conn.commit() | |
| last_id = cursor.lastrowid | |
| conn.close() | |
| await backup_db_to_github(user["username"], dbname) | |
| return {"message": "Row inserted", "id": last_id} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Delete row βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def delete_row( | |
| dbname: str, | |
| table_name: str, | |
| row_id: int, | |
| pk_col: str = "id", | |
| user: dict = Depends(get_current_user) | |
| ): | |
| try: | |
| conn = get_conn(user["username"], dbname) | |
| conn.execute(f"DELETE FROM `{table_name}` WHERE `{pk_col}` = ?", (row_id,)) | |
| conn.commit() | |
| conn.close() | |
| await backup_db_to_github(user["username"], dbname) | |
| return {"message": f"Row {row_id} deleted"} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Export database as SQL βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def export_database(dbname: str, user: dict = Depends(get_current_user)): | |
| try: | |
| db_path = get_db_path(user["username"], dbname) | |
| if not db_path.exists(): | |
| raise HTTPException(404, "Database not found") | |
| conn = sqlite3.connect(str(db_path)) | |
| sql_lines = [] | |
| sql_lines.append(f"-- PHP Hosting Database Export") | |
| sql_lines.append(f"-- Database: {dbname}") | |
| sql_lines.append(f"-- Exported: {datetime.utcnow().isoformat()}") | |
| sql_lines.append(f"-- User: {user['username']}") | |
| sql_lines.append("") | |
| for line in conn.iterdump(): | |
| sql_lines.append(line) | |
| conn.close() | |
| sql_content = "\n".join(sql_lines) | |
| return Response( | |
| content=sql_content, | |
| media_type="application/sql", | |
| headers={"Content-Disposition": f"attachment; filename={dbname}.sql"} | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Generate PHP connection code βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate_php_connection(username: str, dbname: str) -> str: | |
| return f'''<?php | |
| // ββ Database Connection ββββββββββββββββββββββββββββββββββββββββββββ | |
| // Database: {dbname} | |
| // Generated by PHP Hosting Platform | |
| $db_path = __DIR__ . "/{dbname}.db"; | |
| try {{ | |
| $pdo = new PDO("sqlite:" . $db_path); | |
| $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); | |
| $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); | |
| // Connection successful! | |
| }} catch(PDOException $e) {{ | |
| die("Connection failed: " . $e->getMessage()); | |
| }} | |
| // Example usage: | |
| // $stmt = $pdo->prepare("SELECT * FROM your_table"); | |
| // $stmt->execute(); | |
| // $rows = $stmt->fetchAll(); | |
| ?>''' | |
| def get_php_connection_code(dbname: str, user: dict = Depends(get_current_user)): | |
| try: | |
| db_path = get_db_path(user["username"], dbname) | |
| if not db_path.exists(): | |
| raise HTTPException(404, "Database not found") | |
| return {"php_code": generate_php_connection(user["username"], dbname)} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| # ββ Storage stats ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def storage_stats(user: dict = Depends(get_current_user)): | |
| try: | |
| user_folder = gh_path(user["username"]) | |
| items = await gh_list_folder(user_folder) | |
| total_size = 0 | |
| for item in items: | |
| if item.get("type") == "dir" and not item["name"].startswith("_"): | |
| repo_items = await gh_list_folder(f"{user_folder}/{item['name']}") | |
| for fi in repo_items: | |
| if fi.get("type") == "file": | |
| total_size += fi.get("size", 0) | |
| db_dir = get_user_db_dir(user["username"]) | |
| db_size = sum(f.stat().st_size for f in db_dir.glob("*.db")) | |
| dbs = list_user_databases(user["username"]) | |
| return { | |
| "files_used_mb": round(total_size / (1024 * 1024), 2), | |
| "db_used_mb": round(db_size / (1024 * 1024), 2), | |
| "total_used_mb": round((total_size + db_size) / (1024 * 1024), 2), | |
| "limit_mb": 500, | |
| "percent_used": round(((total_size + db_size) / (500 * 1024 * 1024)) * 100, 2), | |
| "databases": len(dbs) | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) |