feat: standalone RecordGod web UI — landing, control-room dash, encrypted secrets vault
- app/vault.py: Fernet-encrypted credential store (app_secret table); key lives only in RECORDGOD_SECRET_KEY (.env, gitignored) — refuses to store anything if the key is unset. - app/settings_routes.py: admin-gated GET/POST /settings/secrets; values encrypted at rest, NEVER returned. Known fields: Woo / Discogs / Cloudflare / DealGod credentials. - site/index.html: recordgod.com landing. site/dash.html: control room (token gate + paste-your-credentials form). main.py loads .env, mounts site at /, store at /store. - Proven: 401 on bad token; DB holds ciphertext (gAAAA…), not plaintext. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3e3b08dc90
commit
e1fcf94f93
35
app/main.py
35
app/main.py
@ -1,22 +1,37 @@
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
# Load .env FIRST so auth/db/vault see their keys at import time.
|
||||
_ENV = pathlib.Path(__file__).resolve().parent.parent / ".env"
|
||||
if _ENV.exists():
|
||||
for _ln in _ENV.read_text().splitlines():
|
||||
_ln = _ln.strip()
|
||||
if _ln and not _ln.startswith("#") and "=" in _ln:
|
||||
_k, _v = _ln.split("=", 1)
|
||||
os.environ.setdefault(_k.strip(), _v.strip())
|
||||
|
||||
from . import __version__
|
||||
from .wowplatter_v1 import router as wowplatter_v1_router
|
||||
from .virtual import router as virtual_router
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.staticfiles import StaticFiles # noqa: E402
|
||||
|
||||
from . import __version__ # noqa: E402
|
||||
from .wowplatter_v1 import router as wowplatter_v1_router # noqa: E402
|
||||
from .virtual import router as virtual_router # noqa: E402
|
||||
from .settings_routes import router as settings_router # noqa: E402
|
||||
|
||||
app = FastAPI(title="RECORDGOD", version=__version__)
|
||||
app.include_router(wowplatter_v1_router)
|
||||
app.include_router(virtual_router)
|
||||
|
||||
# Serve the raw Three.js storefront same-origin (so /store can fetch /virtual/scene, no CORS).
|
||||
WEBSTORE = pathlib.Path(__file__).resolve().parent.parent / "webstore"
|
||||
if WEBSTORE.is_dir():
|
||||
app.mount("/store", StaticFiles(directory=str(WEBSTORE), html=True), name="store")
|
||||
app.include_router(settings_router)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
return {"ok": True, "service": "recordgod", "version": __version__}
|
||||
|
||||
|
||||
_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
# Mounts come last so the API routes + /healthz match first; "/" serves the landing/dash site.
|
||||
if (_ROOT / "webstore").is_dir():
|
||||
app.mount("/store", StaticFiles(directory=str(_ROOT / "webstore"), html=True), name="store")
|
||||
if (_ROOT / "site").is_dir():
|
||||
app.mount("/", StaticFiles(directory=str(_ROOT / "site"), html=True), name="site")
|
||||
|
||||
48
app/settings_routes.py
Normal file
48
app/settings_routes.py
Normal file
@ -0,0 +1,48 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import vault
|
||||
from .auth import require_token
|
||||
from .db import get_db
|
||||
|
||||
# Admin-gated. The dash POSTs credentials in; values are encrypted at rest and NEVER returned.
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
# The credentials the dash knows how to collect (name → human label).
|
||||
KNOWN = {
|
||||
"woo_base_url": "WooCommerce store URL (https://…)",
|
||||
"woo_consumer_key": "WooCommerce REST consumer key (ck_…)",
|
||||
"woo_consumer_secret": "WooCommerce REST consumer secret (cs_…)",
|
||||
"discogs_consumer_key": "Discogs OAuth consumer key",
|
||||
"discogs_consumer_secret": "Discogs OAuth consumer secret",
|
||||
"discogs_token": "Discogs personal access token",
|
||||
"cloudflare_api_token": "Cloudflare API token",
|
||||
"dealgod_api_key": "DealGod API key (X-Api-Key)",
|
||||
}
|
||||
|
||||
|
||||
class SecretIn(BaseModel):
|
||||
name: str
|
||||
value: str
|
||||
|
||||
|
||||
@router.get("/secrets")
|
||||
async def list_secrets(ident=Depends(require_token), db=Depends(get_db)):
|
||||
have = {s["name"]: s["updated_at"] for s in await vault.list_secret_names(db)}
|
||||
return {"ok": True, "fields": [
|
||||
{"name": n, "label": label, "set": n in have, "updated_at": have.get(n)}
|
||||
for n, label in KNOWN.items()
|
||||
]}
|
||||
|
||||
|
||||
@router.post("/secrets")
|
||||
async def save_secret(body: SecretIn, ident=Depends(require_token), db=Depends(get_db)):
|
||||
if body.name not in KNOWN:
|
||||
raise HTTPException(400, "unknown secret name")
|
||||
if not body.value.strip():
|
||||
raise HTTPException(400, "empty value")
|
||||
try:
|
||||
await vault.set_secret(db, body.name, body.value.strip())
|
||||
except vault.SecretsUnconfigured as e:
|
||||
raise HTTPException(503, str(e))
|
||||
return {"ok": True, "name": body.name, "set": True}
|
||||
48
app/vault.py
Normal file
48
app/vault.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Encrypted secrets vault. Credentials are stored as Fernet ciphertext in app_secret;
|
||||
the key lives only in the environment (RECORDGOD_SECRET_KEY), never in the DB or the repo.
|
||||
If the key isn't set we REFUSE to store secrets — never silently store them in plaintext."""
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from sqlalchemy import text
|
||||
|
||||
_key = os.getenv("RECORDGOD_SECRET_KEY")
|
||||
_fernet = Fernet(_key.encode()) if _key else None
|
||||
|
||||
|
||||
class SecretsUnconfigured(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _f():
|
||||
if _fernet is None:
|
||||
raise SecretsUnconfigured(
|
||||
"RECORDGOD_SECRET_KEY not set — refusing to store secrets unencrypted")
|
||||
return _fernet
|
||||
|
||||
|
||||
async def set_secret(db, name, plaintext):
|
||||
enc = _f().encrypt(plaintext.encode())
|
||||
await db.execute(text("""
|
||||
INSERT INTO app_secret (name, value_enc, updated_at) VALUES (:n, :v, now())
|
||||
ON CONFLICT (name) DO UPDATE SET value_enc = EXCLUDED.value_enc, updated_at = now()
|
||||
"""), {"n": name, "v": enc})
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_secret(db, name):
|
||||
"""Decrypt one secret for server-side use (e.g. the Woo client). Never exposed over HTTP."""
|
||||
row = (await db.execute(text("SELECT value_enc FROM app_secret WHERE name = :n"),
|
||||
{"n": name})).first()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
return _f().decrypt(bytes(row[0])).decode()
|
||||
except InvalidToken:
|
||||
return None # key rotated / corrupt — treat as not-set rather than crash
|
||||
|
||||
|
||||
async def list_secret_names(db):
|
||||
rows = await db.execute(text("SELECT name, updated_at FROM app_secret ORDER BY name"))
|
||||
return [{"name": r[0], "updated_at": r[1].isoformat() if r[1] else None}
|
||||
for r in rows]
|
||||
@ -3,3 +3,4 @@ uvicorn[standard]
|
||||
sqlalchemy[asyncio]
|
||||
asyncpg
|
||||
httpx
|
||||
cryptography
|
||||
|
||||
@ -86,5 +86,13 @@ CREATE TABLE IF NOT EXISTS sale_items (
|
||||
CREATE INDEX IF NOT EXISTS sale_items_sale_idx ON sale_items (sale_id);
|
||||
CREATE INDEX IF NOT EXISTS sale_items_release_idx ON sale_items (release_id);
|
||||
|
||||
-- Encrypted credential vault. Values are Fernet ciphertext; the key lives in the environment
|
||||
-- (RECORDGOD_SECRET_KEY), never here. Woo/Discogs/Cloudflare/DealGod tokens land here via /settings.
|
||||
CREATE TABLE IF NOT EXISTS app_secret (
|
||||
name text PRIMARY KEY,
|
||||
value_enc bytea NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- pgvector (semantic search / "records like this" / newsletter copy): add when keyword search
|
||||
-- measurably falls short, not before.
|
||||
|
||||
107
site/dash.html
Normal file
107
site/dash.html
Normal file
@ -0,0 +1,107 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RecordGod — control room</title>
|
||||
<style>
|
||||
:root{--pink:#ff5db1;--ink:#0c0c0e;--mut:#9a9aa6;--card:#141418;--line:#26262c}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--ink);color:#f0f0f2;font:15px/1.55 system-ui,sans-serif}
|
||||
.wrap{max-width:660px;margin:0 auto;padding:6vh 22px}
|
||||
h1{font-size:28px;font-weight:600;margin:0}
|
||||
h1 b{color:var(--pink)}
|
||||
.sub{color:var(--mut);margin:.2em 0 28px}
|
||||
.card{border:1px solid var(--line);border-radius:12px;padding:18px;background:var(--card);margin:0 0 18px}
|
||||
.card h2{font-size:15px;font-weight:500;margin:0 0 12px;color:#fff}
|
||||
label{display:block;font-size:13px;color:var(--mut);margin:12px 0 4px}
|
||||
input{width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;background:#0e0e11;color:#eee;font:14px system-ui}
|
||||
.field{display:flex;gap:8px;align-items:flex-end}
|
||||
.field input{flex:1}
|
||||
button{padding:10px 14px;border:0;border-radius:8px;background:var(--pink);color:#1a0a14;font:500 14px system-ui;cursor:pointer;white-space:nowrap}
|
||||
button.ghost{background:#1d1d22;color:#ccc;border:1px solid var(--line)}
|
||||
.ok{color:#46d18a;font-size:12px;margin-left:6px}
|
||||
.gate{text-align:center;padding:30px 0}
|
||||
#app{display:none}
|
||||
.soon{color:#55555f;font-size:13px}
|
||||
a{color:var(--pink)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Record<b>God</b> · control room</h1>
|
||||
<div class="sub"><a href="/">← home</a> · <a href="/store/">virtual store</a></div>
|
||||
|
||||
<div id="gate" class="card gate">
|
||||
<h2>Sign in</h2>
|
||||
<label>Admin token</label>
|
||||
<div class="field">
|
||||
<input id="tok" type="password" placeholder="RECORDGOD_TOKEN (from your .env)">
|
||||
<button onclick="signin()">Enter</button>
|
||||
</div>
|
||||
<div id="gerr" class="soon" style="margin-top:10px"></div>
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
<div class="card">
|
||||
<h2>Credentials <span class="soon">— encrypted at rest, never shown back</span></h2>
|
||||
<div id="secrets"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Orders (WooCommerce)</h2>
|
||||
<p class="soon">Save your Woo store URL + consumer key/secret above, then live orders land here —
|
||||
managed without wp-admin. (Coming next.)</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Discogs</h2>
|
||||
<p class="soon">Save your Discogs OAuth/token above to enrich intake from the live API and your wantlist.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
let TOKEN = localStorage.getItem('rg_token') || '';
|
||||
|
||||
function hdr(){ return { 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' }; }
|
||||
|
||||
async function signin(){
|
||||
TOKEN = $('#tok').value.trim();
|
||||
const r = await fetch('/settings/secrets', { headers: hdr() });
|
||||
if(!r.ok){ $('#gerr').textContent = 'invalid token (' + r.status + ')'; return; }
|
||||
localStorage.setItem('rg_token', TOKEN);
|
||||
$('#gate').style.display = 'none'; $('#app').style.display = 'block';
|
||||
renderSecrets(await r.json());
|
||||
}
|
||||
|
||||
function renderSecrets(data){
|
||||
const box = $('#secrets'); box.innerHTML = '';
|
||||
data.fields.forEach(f => {
|
||||
const wrap = document.createElement('div');
|
||||
const status = f.set ? `<span class="ok">✓ set ${(f.updated_at||'').slice(0,10)}</span>` : '';
|
||||
wrap.innerHTML = `<label>${f.label} ${status}</label>
|
||||
<div class="field">
|
||||
<input type="password" placeholder="${f.set ? '•••••• (replace to update)' : 'paste value'}" data-name="${f.name}">
|
||||
<button class="ghost" data-save="${f.name}">Save</button>
|
||||
</div>`;
|
||||
box.appendChild(wrap);
|
||||
});
|
||||
box.querySelectorAll('[data-save]').forEach(btn => btn.onclick = () => save(btn.dataset.save, btn));
|
||||
}
|
||||
|
||||
async function save(name, btn){
|
||||
const input = document.querySelector(`input[data-name="${name}"]`);
|
||||
const value = input.value.trim();
|
||||
if(!value) return;
|
||||
btn.textContent = '…';
|
||||
const r = await fetch('/settings/secrets', { method:'POST', headers: hdr(), body: JSON.stringify({ name, value }) });
|
||||
btn.textContent = r.ok ? 'Saved' : 'Error';
|
||||
if(r.ok){ input.value=''; input.placeholder='•••••• (replace to update)';
|
||||
btn.previousElementSibling; setTimeout(()=>btn.textContent='Save', 1500); }
|
||||
}
|
||||
|
||||
// auto-resume if a token is already stored
|
||||
if(TOKEN){ $('#tok').value = TOKEN; signin(); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
50
site/index.html
Normal file
50
site/index.html
Normal file
@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RecordGod — the records engine</title>
|
||||
<style>
|
||||
:root{--pink:#ff5db1;--ink:#0c0c0e;--mut:#9a9aa6}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;min-height:100%;background:var(--ink);color:#f0f0f2;font:16px/1.6 system-ui,sans-serif}
|
||||
.wrap{max-width:860px;margin:0 auto;padding:8vh 24px 6vh}
|
||||
.tag{letter-spacing:.18em;text-transform:uppercase;color:var(--mut);font-size:13px}
|
||||
h1{font-size:clamp(40px,9vw,76px);margin:.1em 0 .1em;font-weight:600;line-height:1}
|
||||
h1 b{color:var(--pink);font-weight:600}
|
||||
.lede{font-size:20px;color:#d8d8de;max-width:620px}
|
||||
.row{display:flex;gap:14px;flex-wrap:wrap;margin:34px 0 0}
|
||||
a.btn{display:inline-flex;align-items:center;gap:8px;padding:13px 22px;border-radius:10px;text-decoration:none;font-weight:500}
|
||||
a.primary{background:var(--pink);color:#1a0a14}
|
||||
a.ghost{border:1px solid #333;color:#eee}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:18px;margin:64px 0 0}
|
||||
.card{border:1px solid #232327;border-radius:12px;padding:18px;background:#141418}
|
||||
.card h3{margin:.1em 0;color:var(--pink);font-weight:500;font-size:16px}
|
||||
.card p{color:var(--mut);font-size:14px;margin:.4em 0 0}
|
||||
.foot{margin-top:64px;color:#55555f;font-size:13px;border-top:1px solid #1c1c20;padding-top:18px}
|
||||
.foot b{color:var(--mut);font-weight:500}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="tag">House of the Hustle · the crate</div>
|
||||
<h1>Record<b>God</b></h1>
|
||||
<p class="lede">The records back-office engine. Inventory, pricing, the 3D store, and order management —
|
||||
one custom stack, no WordPress paper box. Powered by Postgres, fed by DealGod.</p>
|
||||
|
||||
<div class="row">
|
||||
<a class="btn primary" href="/store/">Enter the virtual store →</a>
|
||||
<a class="btn ghost" href="/dash.html">Control room</a>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card"><h3>The crate</h3><p>Walk the shop in 3D, dig through bins, pull a sleeve to inspect it.</p></div>
|
||||
<div class="card"><h3>Locate stock</h3><p>Any release → the exact crate and slot it lives in.</p></div>
|
||||
<div class="card"><h3>Live pricing</h3><p>DealGod's cross-store market value on every shelf.</p></div>
|
||||
<div class="card"><h3>Orders, your way</h3><p>WooCommerce orders managed here — no wp-admin.</p></div>
|
||||
</div>
|
||||
|
||||
<div class="foot">Run from the VPS, not inside WordPress · talks to <b>DealGod</b> · honours <b>WowPlatter</b>, our founder.</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user