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
|
import pathlib
|
||||||
|
|
||||||
from fastapi import FastAPI
|
# Load .env FIRST so auth/db/vault see their keys at import time.
|
||||||
from fastapi.staticfiles import StaticFiles
|
_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 fastapi import FastAPI # noqa: E402
|
||||||
from .wowplatter_v1 import router as wowplatter_v1_router
|
from fastapi.staticfiles import StaticFiles # noqa: E402
|
||||||
from .virtual import router as virtual_router
|
|
||||||
|
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 = FastAPI(title="RECORDGOD", version=__version__)
|
||||||
app.include_router(wowplatter_v1_router)
|
app.include_router(wowplatter_v1_router)
|
||||||
app.include_router(virtual_router)
|
app.include_router(virtual_router)
|
||||||
|
app.include_router(settings_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.get("/healthz")
|
@app.get("/healthz")
|
||||||
async def healthz():
|
async def healthz():
|
||||||
return {"ok": True, "service": "recordgod", "version": __version__}
|
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]
|
sqlalchemy[asyncio]
|
||||||
asyncpg
|
asyncpg
|
||||||
httpx
|
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_sale_idx ON sale_items (sale_id);
|
||||||
CREATE INDEX IF NOT EXISTS sale_items_release_idx ON sale_items (release_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
|
-- pgvector (semantic search / "records like this" / newsletter copy): add when keyword search
|
||||||
-- measurably falls short, not before.
|
-- 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