darkfire514 commited on
Commit
1e6ac2e
·
verified ·
1 Parent(s): 62b22d6

Update api.py

Browse files
Files changed (1) hide show
  1. api.py +132 -24
api.py CHANGED
@@ -1,22 +1,25 @@
1
  import hashlib
2
  import os
3
  import secrets
 
4
  from datetime import datetime
5
  from typing import List, Optional
6
 
7
  import psycopg2
8
- from fastapi import FastAPI, Header, HTTPException
 
9
  from pydantic import BaseModel
10
 
11
  app = FastAPI(
12
  title="PostgreSQL General HF API",
13
  description="A lightweight PostgreSQL API service for Hugging Face Docker Spaces.",
14
- version="1.0.0",
15
  )
16
 
17
  POSTGRES_USER = os.environ.get("POSTGRES_USER", "admin")
18
  POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD")
19
  POSTGRES_DB = os.environ.get("POSTGRES_DB", "appdb")
 
20
 
21
  DATABASE_URL = os.environ.get(
22
  "DATABASE_URL",
@@ -96,15 +99,26 @@ def startup():
96
  init_tables()
97
 
98
 
99
- def verify_api_key(authorization: Optional[str], required_scope: Optional[str] = None):
 
 
 
100
  if not authorization:
101
- raise HTTPException(status_code=401, detail="Missing Authorization header")
102
 
103
  if not authorization.startswith("Bearer "):
104
  raise HTTPException(status_code=401, detail="Invalid Authorization format")
105
 
106
- api_key = authorization.replace("Bearer ", "", 1).strip()
107
- key_hash = hash_key(api_key)
 
 
 
 
 
 
 
 
108
 
109
  conn = get_conn()
110
  cur = conn.cursor()
@@ -154,14 +168,61 @@ def verify_api_key(authorization: Optional[str], required_scope: Optional[str] =
154
  return {"id": key_id, "name": name, "scopes": scopes}
155
 
156
 
157
- @app.get("/")
158
- def root():
159
- return {
160
- "service": "PostgreSQL General HF API",
161
- "status": "ok",
162
- "docs": "/docs",
163
- "health": "/api/health",
164
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
 
167
  @app.get("/api/health")
@@ -170,8 +231,11 @@ def health():
170
 
171
 
172
  @app.get("/api/db-health")
173
- def db_health(authorization: Optional[str] = Header(default=None)):
174
- verify_api_key(authorization, required_scope="read")
 
 
 
175
 
176
  conn = get_conn()
177
  cur = conn.cursor()
@@ -183,6 +247,15 @@ def db_health(authorization: Optional[str] = Header(default=None)):
183
  return {"status": "ok", "db": result[0]}
184
 
185
 
 
 
 
 
 
 
 
 
 
186
  class CreateApiKeyRequest(BaseModel):
187
  name: str
188
  scopes: List[str] = ["read", "write"]
@@ -305,6 +378,27 @@ def revoke_api_key(
305
  return {"id": row[0], "name": row[1], "status": "revoked"}
306
 
307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  class FileRecord(BaseModel):
309
  user_id: Optional[str] = None
310
  filename: str
@@ -314,8 +408,12 @@ class FileRecord(BaseModel):
314
 
315
 
316
  @app.post("/api/files")
317
- def create_file(record: FileRecord, authorization: Optional[str] = Header(default=None)):
318
- verify_api_key(authorization, required_scope="write")
 
 
 
 
319
 
320
  conn = get_conn()
321
  cur = conn.cursor()
@@ -343,8 +441,11 @@ def create_file(record: FileRecord, authorization: Optional[str] = Header(defaul
343
 
344
 
345
  @app.get("/api/files")
346
- def list_files(authorization: Optional[str] = Header(default=None)):
347
- verify_api_key(authorization, required_scope="read")
 
 
 
348
 
349
  conn = get_conn()
350
  cur = conn.cursor()
@@ -385,8 +486,12 @@ class ArticleRecord(BaseModel):
385
 
386
 
387
  @app.post("/api/rss/articles")
388
- def create_article(article: ArticleRecord, authorization: Optional[str] = Header(default=None)):
389
- verify_api_key(authorization, required_scope="write")
 
 
 
 
390
 
391
  conn = get_conn()
392
  cur = conn.cursor()
@@ -413,8 +518,11 @@ def create_article(article: ArticleRecord, authorization: Optional[str] = Header
413
 
414
 
415
  @app.get("/api/rss/articles")
416
- def list_articles(authorization: Optional[str] = Header(default=None)):
417
- verify_api_key(authorization, required_scope="read")
 
 
 
418
 
419
  conn = get_conn()
420
  cur = conn.cursor()
 
1
  import hashlib
2
  import os
3
  import secrets
4
+ import subprocess
5
  from datetime import datetime
6
  from typing import List, Optional
7
 
8
  import psycopg2
9
+ from fastapi import FastAPI, Header, HTTPException, Query
10
+ from fastapi.responses import HTMLResponse
11
  from pydantic import BaseModel
12
 
13
  app = FastAPI(
14
  title="PostgreSQL General HF API",
15
  description="A lightweight PostgreSQL API service for Hugging Face Docker Spaces.",
16
+ version="1.1.0",
17
  )
18
 
19
  POSTGRES_USER = os.environ.get("POSTGRES_USER", "admin")
20
  POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD")
21
  POSTGRES_DB = os.environ.get("POSTGRES_DB", "appdb")
22
+ BACKUP_DIR = os.environ.get("BACKUP_DIR", "/data/backups")
23
 
24
  DATABASE_URL = os.environ.get(
25
  "DATABASE_URL",
 
99
  init_tables()
100
 
101
 
102
+ def extract_api_key(authorization: Optional[str], api_key: Optional[str]) -> str:
103
+ if api_key:
104
+ return api_key.strip()
105
+
106
  if not authorization:
107
+ raise HTTPException(status_code=401, detail="Missing API key. Use Authorization: Bearer YOUR_KEY or ?api_key=YOUR_KEY")
108
 
109
  if not authorization.startswith("Bearer "):
110
  raise HTTPException(status_code=401, detail="Invalid Authorization format")
111
 
112
+ return authorization.replace("Bearer ", "", 1).strip()
113
+
114
+
115
+ def verify_api_key(
116
+ authorization: Optional[str],
117
+ api_key: Optional[str] = None,
118
+ required_scope: Optional[str] = None,
119
+ ):
120
+ raw_key = extract_api_key(authorization, api_key)
121
+ key_hash = hash_key(raw_key)
122
 
123
  conn = get_conn()
124
  cur = conn.cursor()
 
168
  return {"id": key_id, "name": name, "scopes": scopes}
169
 
170
 
171
+ DASHBOARD_HTML = r"""
172
+ <!doctype html>
173
+ <html lang="en">
174
+ <head>
175
+ <meta charset="utf-8" />
176
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
177
+ <title>PostgreSQL General HF</title>
178
+ <style>
179
+ :root{--bg:#070b1a;--panel:#111827;--panel2:#0d1324;--line:#25314b;--text:#e5edf9;--muted:#94a3b8;--brand:#6d5dfc;--brand2:#35d399;--danger:#ff4d6d;--code:#050816}
180
+ *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
181
+ .top{height:58px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding:0 22px;background:#0b1020;position:sticky;top:0;z-index:2}
182
+ .brand{display:flex;align-items:center;gap:12px;font-weight:800;font-size:18px}.logo{width:34px;height:34px;border-radius:7px;background:var(--brand);display:grid;place-items:center;font-weight:900}.online{font-size:12px;color:var(--brand2);background:#06281f;border:1px solid #0a6b50;padding:5px 10px;border-radius:6px}.endpoint{font-family:ui-monospace,Menlo,Consolas,monospace;color:#a5b4fc;font-size:13px}.backup{border:0;background:var(--brand);color:white;padding:10px 16px;border-radius:7px;font-weight:700;cursor:pointer}
183
+ .wrap{display:grid;grid-template-columns:210px 1fr;min-height:calc(100vh - 58px)}.side{border-right:1px solid var(--line);background:#080d1d;padding:18px 14px}.nav{padding:12px;border-radius:8px;color:var(--muted);margin-bottom:8px;cursor:pointer}.nav.active,.nav:hover{background:#172039;color:#a5b4fc}.main{padding:28px;max-width:1280px}.h1{font-size:26px;font-weight:900;margin:0}.sub{color:var(--muted);margin-top:8px}.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin-top:24px}.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}.input,.select{background:#0a1020;border:1px solid #33415f;color:var(--text);border-radius:8px;padding:11px 12px;outline:none}.input{min-width:260px}.btn{background:var(--brand);color:white;border:0;border-radius:8px;padding:11px 14px;font-weight:700;cursor:pointer}.btn.secondary{background:#1d2942}.btn.danger{background:transparent;color:var(--danger);border:1px solid #5a1e2a}.small{font-size:12px;color:var(--muted)}.table{width:100%;border-collapse:collapse;margin-top:12px}.table th,.table td{border-bottom:1px solid var(--line);padding:12px;text-align:left}.table th{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}.code{background:var(--code);border:1px solid #1c2740;border-radius:8px;padding:12px;font-family:ui-monospace,Menlo,Consolas,monospace;overflow:auto;color:#dbeafe}.pill{display:inline-block;border:1px solid #33415f;color:#cbd5e1;border-radius:999px;padding:3px 8px;font-size:12px;margin-right:4px}.hidden{display:none}.toast{position:fixed;right:20px;bottom:20px;background:#102033;border:1px solid #2b4267;padding:14px 16px;border-radius:10px;color:white;max-width:520px;z-index:5}.modal{position:fixed;inset:0;background:rgba(0,0,0,.65);display:none;align-items:center;justify-content:center;padding:20px;z-index:4}.modal.show{display:flex}.modal-card{background:#111827;border:1px solid var(--line);border-radius:16px;max-width:920px;width:100%;padding:22px}.linkbox{display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;margin-top:8px}
184
+ </style>
185
+ </head>
186
+ <body>
187
+ <div class="top"><div class="brand"><div class="logo">PG</div><span>PostgreSQL General</span><span class="online">● SYSTEM ONLINE</span></div><div class="row"><span class="endpoint" id="endpoint"></span><button class="backup" onclick="runBackup()">One-Click Backup</button></div></div>
188
+ <div class="wrap"><aside class="side"><div class="nav active" onclick="show('dashboard')">Dashboard</div><div class="nav" onclick="show('keys')">API Keys</div><div class="nav" onclick="show('reference')">API Reference</div><div class="nav" onclick="show('settings')">Settings</div><div class="nav" onclick="location.href='/docs'">FastAPI Docs</div></aside>
189
+ <main class="main">
190
+ <section id="dashboard"><h1 class="h1">Dashboard</h1><p class="sub">Manage PostgreSQL API access without remembering curl commands.</p><div class="grid"><div class="card"><b>Status</b><div class="code" id="health">Loading...</div></div><div class="card"><b>Quick Start</b><p class="small">1. Save admin credentials in Settings. 2. Generate an API Key. 3. Copy ready-to-use API links.</p><button class="btn" onclick="show('settings')">Configure Admin</button> <button class="btn secondary" onclick="show('keys')">Generate Key</button></div></div></section>
191
+ <section id="keys" class="hidden"><h1 class="h1">API Integration</h1><p class="sub">Generate API Keys and ready-to-use links for external projects.</p><div class="card"><h3>Generate API Key</h3><div class="row"><input id="keyName" class="input" placeholder="Key name, e.g. rss_project"><label><input type="checkbox" id="scopeRead" checked> read</label><label><input type="checkbox" id="scopeWrite" checked> write</label><button class="btn" onclick="createKey()">Generate Key</button><button class="btn secondary" onclick="loadKeys()">Refresh</button></div><p class="small">Full API Key is shown only once. Existing keys only show prefixes.</p><table class="table"><thead><tr><th>ID</th><th>Name</th><th>Prefix</th><th>Scopes</th><th>Active</th><th>Last used</th><th>Action</th></tr></thead><tbody id="keyRows"></tbody></table></div></section>
192
+ <section id="reference" class="hidden"><h1 class="h1">API Reference</h1><p class="sub">Use a key as <code>?api_key=YOUR_KEY</code> for convenient links, or as <code>Authorization: Bearer YOUR_KEY</code>.</p><div class="card"><h3>Base URL</h3><div class="code" id="baseUrl"></div><h3>Common API Links</h3><div class="code" id="exampleLinks"></div></div></section>
193
+ <section id="settings" class="hidden"><h1 class="h1">Settings</h1><p class="sub">Credentials are stored only in this browser's localStorage for calling admin endpoints.</p><div class="card"><div class="grid"><input id="adminUser" class="input" placeholder="Admin user, default admin"><input id="adminPass" class="input" type="password" placeholder="POSTGRES_PASSWORD"></div><br><button class="btn" onclick="saveSettings()">Save Settings</button><button class="btn secondary" onclick="clearSettings()">Clear</button></div></section>
194
+ </main></div>
195
+ <div class="modal" id="modal"><div class="modal-card"><h2>API Key Generated</h2><p class="small">Copy and save this key now. It will not be shown again.</p><div class="code" id="newKey"></div><h3>Ready-to-use API links</h3><div id="newLinks"></div><br><button class="btn" onclick="copyText(document.getElementById('newKey').innerText)">Copy Key</button> <button class="btn secondary" onclick="closeModal()">Close</button></div></div>
196
+ <div id="toast" class="toast hidden"></div>
197
+ <script>
198
+ const base = location.origin;
199
+ document.getElementById('endpoint').textContent = location.host;
200
+ document.getElementById('baseUrl').textContent = base;
201
+ function el(id){return document.getElementById(id)}
202
+ function toast(msg){const t=el('toast');t.textContent=msg;t.classList.remove('hidden');setTimeout(()=>t.classList.add('hidden'),3500)}
203
+ function show(id){document.querySelectorAll('main section').forEach(s=>s.classList.add('hidden'));el(id).classList.remove('hidden');document.querySelectorAll('.nav').forEach(n=>n.classList.remove('active'));[...document.querySelectorAll('.nav')].find(n=>n.textContent.toLowerCase().includes(id.split('-')[0]))?.classList.add('active'); if(id==='keys') loadKeys();}
204
+ function adminHeaders(){return {'X-Admin-User': localStorage.getItem('pg_admin_user')||'admin','X-Admin-Password': localStorage.getItem('pg_admin_pass')||'', 'Content-Type':'application/json'}}
205
+ function saveSettings(){localStorage.setItem('pg_admin_user', el('adminUser').value||'admin');localStorage.setItem('pg_admin_pass', el('adminPass').value||'');toast('Settings saved in this browser.');}
206
+ function clearSettings(){localStorage.removeItem('pg_admin_user');localStorage.removeItem('pg_admin_pass');el('adminUser').value='admin';el('adminPass').value='';toast('Settings cleared.');}
207
+ async function req(url, opts={}){const r=await fetch(url,opts);const text=await r.text();let data;try{data=JSON.parse(text)}catch{data=text}if(!r.ok)throw new Error(typeof data==='string'?data:(data.detail||JSON.stringify(data)));return data}
208
+ async function loadHealth(){try{el('health').textContent=JSON.stringify(await req('/api/health'),null,2)}catch(e){el('health').textContent=e.message}}
209
+ async function loadKeys(){try{const rows=await req('/admin/api-keys',{headers:adminHeaders()});el('keyRows').innerHTML=rows.map(k=>`<tr><td>${k.id}</td><td>${k.name}</td><td><code>${k.key_prefix}</code></td><td>${(k.scopes||[]).map(s=>`<span class="pill">${s}</span>`).join('')}</td><td>${k.is_active?'Yes':'No'}</td><td>${k.last_used_at||'Never'}</td><td>${k.is_active?`<button class="btn danger" onclick="revokeKey(${k.id})">Revoke</button>`:''}</td></tr>`).join('')||'<tr><td colspan="7" class="small">No keys yet.</td></tr>'}catch(e){toast(e.message)}}
210
+ function linksFor(key){return [`${base}/api/db-health?api_key=${encodeURIComponent(key)}`,`${base}/api/files?api_key=${encodeURIComponent(key)}`,`${base}/api/rss/articles?api_key=${encodeURIComponent(key)}`]}
211
+ function renderLinks(key){return linksFor(key).map(u=>`<div class="linkbox"><div class="code">${u}</div><button class="btn secondary" onclick="copyText('${u}')">Copy</button></div>`).join('')}
212
+ async function createKey(){const name=el('keyName').value.trim();if(!name){toast('Please enter a key name.');return}const scopes=[];if(el('scopeRead').checked)scopes.push('read');if(el('scopeWrite').checked)scopes.push('write');try{const data=await req('/admin/api-keys',{method:'POST',headers:adminHeaders(),body:JSON.stringify({name,scopes})});el('newKey').textContent=data.api_key;el('newLinks').innerHTML=renderLinks(data.api_key);el('modal').classList.add('show');loadKeys();renderExample(data.api_key)}catch(e){toast(e.message)}}
213
+ async function revokeKey(id){if(!confirm('Revoke this API Key?'))return;try{await req('/admin/api-keys/revoke',{method:'POST',headers:adminHeaders(),body:JSON.stringify({id})});toast('API Key revoked.');loadKeys()}catch(e){toast(e.message)}}
214
+ async function runBackup(){try{const data=await req('/admin/backups/run',{method:'POST',headers:adminHeaders()});toast('Backup created: '+data.latest_file)}catch(e){toast(e.message)}}
215
+ function copyText(txt){navigator.clipboard.writeText(txt);toast('Copied.')}function closeModal(){el('modal').classList.remove('show')}
216
+ function renderExample(key='YOUR_API_KEY'){el('exampleLinks').textContent=linksFor(key).join('\n')+'\n\nHeader style:\ncurl "'+base+'/api/db-health" -H "Authorization: Bearer '+key+'"'}
217
+ el('adminUser').value=localStorage.getItem('pg_admin_user')||'admin';el('adminPass').value=localStorage.getItem('pg_admin_pass')||'';renderExample();loadHealth();
218
+ </script>
219
+ </body></html>
220
+ """
221
+
222
+
223
+ @app.get("/", response_class=HTMLResponse)
224
+ def dashboard():
225
+ return HTMLResponse(DASHBOARD_HTML)
226
 
227
 
228
  @app.get("/api/health")
 
231
 
232
 
233
  @app.get("/api/db-health")
234
+ def db_health(
235
+ authorization: Optional[str] = Header(default=None),
236
+ api_key: Optional[str] = Query(default=None),
237
+ ):
238
+ verify_api_key(authorization, api_key, required_scope="read")
239
 
240
  conn = get_conn()
241
  cur = conn.cursor()
 
247
  return {"status": "ok", "db": result[0]}
248
 
249
 
250
+ @app.get("/api/db")
251
+ def api_db_gateway(
252
+ authorization: Optional[str] = Header(default=None),
253
+ api_key: Optional[str] = Query(default=None),
254
+ ):
255
+ verify_api_key(authorization, api_key, required_scope="read")
256
+ return {"status": "ok", "message": "PostgreSQL API gateway is online", "docs": "/docs"}
257
+
258
+
259
  class CreateApiKeyRequest(BaseModel):
260
  name: str
261
  scopes: List[str] = ["read", "write"]
 
378
  return {"id": row[0], "name": row[1], "status": "revoked"}
379
 
380
 
381
+ @app.post("/admin/backups/run")
382
+ def run_backup(
383
+ x_admin_user: Optional[str] = Header(default=None),
384
+ x_admin_password: Optional[str] = Header(default=None),
385
+ ):
386
+ verify_admin(x_admin_user, x_admin_password)
387
+ os.makedirs(BACKUP_DIR, exist_ok=True)
388
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
389
+ backup_file = os.path.join(BACKUP_DIR, f"backup_{POSTGRES_DB}_{timestamp}.sql")
390
+ latest_file = os.path.join(BACKUP_DIR, "latest.sql")
391
+ env = os.environ.copy()
392
+ env["PGPASSWORD"] = POSTGRES_PASSWORD or ""
393
+ cmd = ["pg_dump", "-h", "127.0.0.1", "-p", "5432", "-U", POSTGRES_USER, "-d", POSTGRES_DB]
394
+ with open(backup_file, "w", encoding="utf-8") as f:
395
+ result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, text=True, env=env)
396
+ if result.returncode != 0:
397
+ raise HTTPException(status_code=500, detail=result.stderr)
398
+ subprocess.run(["cp", backup_file, latest_file], check=False)
399
+ return {"status": "ok", "backup_file": backup_file, "latest_file": latest_file}
400
+
401
+
402
  class FileRecord(BaseModel):
403
  user_id: Optional[str] = None
404
  filename: str
 
408
 
409
 
410
  @app.post("/api/files")
411
+ def create_file(
412
+ record: FileRecord,
413
+ authorization: Optional[str] = Header(default=None),
414
+ api_key: Optional[str] = Query(default=None),
415
+ ):
416
+ verify_api_key(authorization, api_key, required_scope="write")
417
 
418
  conn = get_conn()
419
  cur = conn.cursor()
 
441
 
442
 
443
  @app.get("/api/files")
444
+ def list_files(
445
+ authorization: Optional[str] = Header(default=None),
446
+ api_key: Optional[str] = Query(default=None),
447
+ ):
448
+ verify_api_key(authorization, api_key, required_scope="read")
449
 
450
  conn = get_conn()
451
  cur = conn.cursor()
 
486
 
487
 
488
  @app.post("/api/rss/articles")
489
+ def create_article(
490
+ article: ArticleRecord,
491
+ authorization: Optional[str] = Header(default=None),
492
+ api_key: Optional[str] = Query(default=None),
493
+ ):
494
+ verify_api_key(authorization, api_key, required_scope="write")
495
 
496
  conn = get_conn()
497
  cur = conn.cursor()
 
518
 
519
 
520
  @app.get("/api/rss/articles")
521
+ def list_articles(
522
+ authorization: Optional[str] = Header(default=None),
523
+ api_key: Optional[str] = Query(default=None),
524
+ ):
525
+ verify_api_key(authorization, api_key, required_scope="read")
526
 
527
  conn = get_conn()
528
  cur = conn.cursor()