Compare commits

...

4 Commits

Author SHA1 Message Date
type-two
00e08a86dd feat(sku): canonical 14-digit timestamp SKUs so RFID/EPC tagging works (new items)
RecordGod generated 'RG'+random-hex SKUs, which the SCANGOD EPC codec rejects
(it requires 14 digits: SKU × 10^9 + release_id packed onto the UHF chip). New
inventory now gets a 14-digit product-added timestamp (YYYYMMDDHHMMSS) via
_fresh_sku(), which bumps a second on collision to stay unique against the sku
PRIMARY KEY. release_id stays its own column (the sku+space+release_id string is
only the physical chip encoding, which the codec already builds).

New items only — existing 'RG' SKUs are untouched (they keep working; they just
aren't RFID-taggable until re-SKU'd, a separate migration if ever wanted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:34:27 +10:00
type-two
61601f1067 money: atomic stock claims, amount validation, discount gate (converge with StoreGod twin)
R1 checkout qty clamp to one-row-per-copy; R2 claim-then-charge in
online checkout with release-on-decline and post-charge write guard;
R3 POS create_sale per-sku atomic claim (matches held copies so
hold->counter completion still works) before booking the sale;
R4 take_payment positive-amount check + atomic amount_paid increment;
R5 POS discount gate (admin only, RecordGod role idiom); R6 log +
re-raise if the order write fails after a successful charge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:02:30 +10:00
type-two
8535389cf1 security(layout): SSRF guard on /layout/import-brand (reject internal hosts, no redirects)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 02:49:09 +10:00
type-two
5ebb997978 security(pii): keep customer SQL dumps out of git and the Docker image
customers.sql (610KB phpMyAdmin dump: wp_rmp_users password hashes + real
customer names/emails/phones/addresses) sat untracked but matched NEITHER
.gitignore NOR .dockerignore, and Dockerfile does COPY . . — so it was one
'git add .' from history and already bakeable into the public image.

- .gitignore:  customers*.sql  (schema.sql stays tracked)
- .dockerignore: *.sql  (startup DDL is inline in app/main.py; no .sql needed at runtime)

Working-tree customers.sql shredded. site-update.sql was already gitignored;
now dockerignored too.

NOTE: rebuild+redeploy the image to purge copies already baked into layers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 02:04:45 +10:00
7 changed files with 122 additions and 34 deletions

View File

@ -12,3 +12,6 @@ ingest_raw/
rarw*.txt rarw*.txt
inertia.txt inertia.txt
*.xlsx *.xlsx
# SQL dumps — customer/order PII + WP password hashes. Never bake into the image;
# the app's startup DDL is inline in app/main.py, so no .sql is needed at runtime.
*.sql

1
.gitignore vendored
View File

@ -12,4 +12,5 @@ inertia.txt
# live-site MariaDB dumps for 3D-store/editor data work — full site export, carries customer/ # live-site MariaDB dumps for 3D-store/editor data work — full site export, carries customer/
# order PII. Local-only; another lane consumes it for robotmonster.party/store racks + editor. # order PII. Local-only; another lane consumes it for robotmonster.party/store racks + editor.
site-update.sql site-update.sql
customers*.sql
*-dump.sql *-dump.sql

View File

@ -1,5 +1,6 @@
import re import re
import secrets import secrets
from datetime import datetime, timedelta
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -547,9 +548,24 @@ class BulkIn(BaseModel):
value: float | int | str | None = None value: float | int | str | None = None
async def _fresh_sku(db):
"""Canonical SKU = 14-digit product-added timestamp (YYYYMMDDHHMMSS) — the format SCANGOD's EPC/RFID
codec requires (packs SKU 14 digits × 10^9 + release_id onto the UHF chip). Bumps a second on
collision so a same-second batch stays unique against the sku PRIMARY KEY.
ponytail: check-then-insert; a truly concurrent same-second add on another connection could still hit
the PK and be ON CONFLICT DO NOTHING'd. Fine for counter/admin stock entry; add retry-on-conflict if
bulk-import concurrency grows."""
base = datetime.now()
for i in range(120):
sku = (base + timedelta(seconds=i)).strftime("%Y%m%d%H%M%S")
if not (await db.execute(text("SELECT 1 FROM inventory WHERE sku = :s"), {"s": sku})).first():
return sku
return (base + timedelta(seconds=120)).strftime("%Y%m%d%H%M%S")
@router.post("/inventory/add") @router.post("/inventory/add")
async def inv_add(body: InvItemIn, ident=Depends(require_token), db=Depends(get_db)): async def inv_add(body: InvItemIn, ident=Depends(require_token), db=Depends(get_db)):
sku = (body.sku or "").strip() or ("RG" + secrets.token_hex(4).upper()) sku = (body.sku or "").strip() or await _fresh_sku(db)
title = body.title title = body.title
if body.release_id and not title: # pull the cached title if intaking by release if body.release_id and not title: # pull the cached title if intaking by release
r = (await db.execute(text("SELECT title FROM disc_cache WHERE release_id=:r"), r = (await db.execute(text("SELECT title FROM disc_cache WHERE release_id=:r"),
@ -629,7 +645,7 @@ async def inv_import(body: ImportIn, ident=Depends(require_token), db=Depends(ge
if not rid: if not rid:
not_found.append(ln) not_found.append(ln)
continue continue
sku = "RG" + secrets.token_hex(4).upper() sku = await _fresh_sku(db)
await db.execute(text(""" await db.execute(text("""
INSERT INTO inventory (sku, store_id, kind, release_id, title, price, qty, in_stock, condition, INSERT INTO inventory (sku, store_id, kind, release_id, title, price, qty, in_stock, condition,
crate_id, status, created_at, updated_at) crate_id, status, created_at, updated_at)

View File

@ -10,6 +10,7 @@ from sqlalchemy import text
from .auth import require_token from .auth import require_token
from .db import get_db from .db import get_db
from .urlguard import safe_public_url
# Storefront customizer — per-store theme + header menu + which elements appear on cards / # Storefront customizer — per-store theme + header menu + which elements appear on cards /
# product pages, in what order. Lets RecordGod dress ANY shop without touching code. # product pages, in what order. Lets RecordGod dress ANY shop without touching code.
@ -71,7 +72,12 @@ async def import_brand(body: UrlIn, ident=Depends(require_token)):
if not url.startswith("http"): if not url.startswith("http"):
url = "https://" + url url = "https://" + url
try: try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True, url = safe_public_url(url)
except ValueError as e:
raise HTTPException(400, f"refused url: {e}")
# ponytail: DNS-rebinding TOCTOU remains; follow_redirects=False closes the redirect bypass
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=False,
headers={"User-Agent": "RecordGod/0.1 brand-import"}) as c: headers={"User-Agent": "RecordGod/0.1 brand-import"}) as c:
html = (await c.get(url)).text html = (await c.get(url)).text
except Exception as e: except Exception as e:

View File

@ -74,6 +74,8 @@ async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get
raise HTTPException(400, "no items") raise HTTPException(400, "no items")
gross = sum(li.unit_price * li.qty for li in body.items) gross = sum(li.unit_price * li.qty for li in body.items)
line_disc = sum(li.discount for li in body.items) line_disc = sum(li.discount for li in body.items)
if (line_disc + body.cart_discount) > 0.001 and ident.get("role") != "admin":
raise HTTPException(403, "discounts need a manager")
sub_after = gross - line_disc - body.cart_discount sub_after = gross - line_disc - body.cart_discount
tax = round(sub_after * body.tax_rate / 100, 2) tax = round(sub_after * body.tax_rate / 100, 2)
total = round(sub_after + tax - body.trade_in, 2) total = round(sub_after + tax - body.trade_in, 2)
@ -85,6 +87,26 @@ async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get
hold_due = body.hold.get("due_date") if is_hold else None hold_due = body.hold.get("due_date") if is_hold else None
sale_number = "S" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper() sale_number = "S" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper()
# CLAIM each copy off the floor (sold, or reserved on a hold) atomically BEFORE booking the sale —
# one statement per sku so a copy that's already gone can't be double-sold. A held copy the counter is
# fulfilling is off the floor already (in_stock=false, status='held') so the guard also matches 'held'.
ist = "held" if is_hold else "sold"
claimed = []
for li in body.items:
got = (await db.execute(text("""
UPDATE inventory SET in_stock = false, status = :ist,
sold_date = CASE WHEN :ist = 'sold' THEN now() ELSE NULL END
WHERE sku = :sku AND store_id = 1 AND (in_stock OR status = 'held') RETURNING sku
"""), {"ist": ist, "sku": li.sku})).first()
if got:
claimed.append(li.sku)
else: # already sold — release our partial claim, bail
if claimed:
await db.execute(text("UPDATE inventory SET in_stock = true, status = 'publish', sold_date = NULL "
"WHERE sku = ANY(:claimed) AND store_id = 1"), {"claimed": claimed})
await db.commit()
raise HTTPException(409, f"{li.sku} just sold")
cust = body.customer_id if body.customer_id else None # don't FK-store the synthetic guest (0) cust = body.customer_id if body.customer_id else None # don't FK-store the synthetic guest (0)
sale_id = (await db.execute(text(""" sale_id = (await db.execute(text("""
INSERT INTO sales (sale_number, customer_id, subtotal, discount_amount, tax_amount, total, INSERT INTO sales (sale_number, customer_id, subtotal, discount_amount, tax_amount, total,
@ -106,13 +128,6 @@ async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get
"""), {"sid": sale_id, "sku": li.sku, "name": li.title or li.sku, "qty": li.qty, """), {"sid": sale_id, "sku": li.sku, "name": li.title or li.sku, "qty": li.qty,
"up": li.unit_price, "lt": li.unit_price * li.qty - li.discount, "up": li.unit_price, "lt": li.unit_price * li.qty - li.discount,
"disc": li.discount, "op": li.unit_price}) "disc": li.discount, "op": li.unit_price})
# take the items off the floor (sold, or reserved on a hold) so they can't be double-sold
await db.execute(text("""
UPDATE inventory SET in_stock = false, status = :ist,
sold_date = CASE WHEN :ist = 'sold' THEN now() ELSE NULL END
WHERE sku = ANY(:skus) AND store_id = 1
"""), {"ist": "held" if is_hold else "sold", "skus": [li.sku for li in body.items]})
await db.commit() await db.commit()
if not is_hold: # cross-channel anti-oversell: delist anything that just sold from Discogs (best-effort) if not is_hold: # cross-channel anti-oversell: delist anything that just sold from Discogs (best-effort)
try: try:
@ -210,15 +225,20 @@ async def email_test(body: EmailIn, ident=Depends(require_token), db=Depends(get
@router.post("/{sale_id}/pay") @router.post("/{sale_id}/pay")
async def take_payment(sale_id: int, body: PayIn, ident=Depends(require_token), db=Depends(get_db)): async def take_payment(sale_id: int, body: PayIn, ident=Depends(require_token), db=Depends(get_db)):
"""Payment toward a held / payment-plan sale. Fully paid → completes it + sells the items.""" """Payment toward a held / payment-plan sale. Fully paid → completes it + sells the items."""
s = (await db.execute(text("SELECT total::float AS total, coalesce(amount_paid,0)::float AS paid FROM sales WHERE id=:i"), if not (await db.execute(text("SELECT 1 FROM sales WHERE id=:i"), {"i": sale_id})).first():
{"i": sale_id})).mappings().first()
if not s:
raise HTTPException(404, "not found") raise HTTPException(404, "not found")
new_paid = round(s["paid"] + body.amount, 2) if body.amount <= 0:
done = new_paid >= s["total"] - 0.005 raise HTTPException(422, "amount must be positive")
# atomic increment so concurrent layby payments sum instead of clobbering each other
r = (await db.execute(text(
"UPDATE sales SET amount_paid = coalesce(amount_paid,0) + :amt WHERE id=:i "
"RETURNING amount_paid::float AS paid, total::float AS total"),
{"amt": body.amount, "i": sale_id})).mappings().first()
new_paid = round(r["paid"], 2)
done = new_paid >= r["total"] - 0.005
await db.execute(text( await db.execute(text(
"UPDATE sales SET amount_paid=:p, payment_status=:ps, status=:st WHERE id=:i"), "UPDATE sales SET payment_status=:ps, status=:st WHERE id=:i"),
{"p": new_paid, "ps": "paid" if done else "hold", {"ps": "paid" if done else "hold",
"st": "completed" if done else "held", "i": sale_id}) "st": "completed" if done else "held", "i": sale_id})
if done: if done:
await db.execute(text(""" await db.execute(text("""
@ -226,7 +246,7 @@ async def take_payment(sale_id: int, body: PayIn, ident=Depends(require_token),
WHERE sku IN (SELECT sku FROM sale_items WHERE sale_id=:i) AND store_id = 1 WHERE sku IN (SELECT sku FROM sale_items WHERE sale_id=:i) AND store_id = 1
"""), {"i": sale_id}) """), {"i": sale_id})
await db.commit() await db.commit()
return {"ok": True, "amount_paid": new_paid, "balance": round(s["total"] - new_paid, 2), "completed": done} return {"ok": True, "amount_paid": new_paid, "balance": round(r["total"] - new_paid, 2), "completed": done}
# ── Customers ──────────────────────────────────────────────────────────────────────────────── # ── Customers ────────────────────────────────────────────────────────────────────────────────

View File

@ -1,6 +1,7 @@
import asyncio import asyncio
import html import html
import json import json
import logging
import re import re
import secrets import secrets
from datetime import datetime from datetime import datetime
@ -30,6 +31,7 @@ from .layout_routes import DEFAULTS
# PUBLIC storefront API — the customer-facing shop reads its theme/layout + catalog here. # PUBLIC storefront API — the customer-facing shop reads its theme/layout + catalog here.
# No auth (theme + in-stock catalog aren't secret); driven by what the builder saves. # No auth (theme + in-stock catalog aren't secret); driven by what the builder saves.
router = APIRouter(prefix="/shop", tags=["shop"]) router = APIRouter(prefix="/shop", tags=["shop"])
log = logging.getLogger("recordgod")
async def _tracking_head(db) -> str: async def _tracking_head(db) -> str:
@ -429,7 +431,7 @@ async def checkout(body: CheckoutIn, db=Depends(get_db)):
raise HTTPException(400, f"unknown item {it.sku}") raise HTTPException(400, f"unknown item {it.sku}")
if not r["in_stock"]: if not r["in_stock"]:
raise HTTPException(409, f"{r.get('title') or it.sku} is already sold") raise HTTPException(409, f"{r.get('title') or it.sku} is already sold")
qty, price = max(1, it.qty), float(r["price"] or 0) qty, price = 1, float(r["price"] or 0) # ponytail: one-row-per-copy — a sku IS one physical item; qty>1 can't be honoured
subtotal += price * qty subtotal += price * qty
weight += (r["weight_g"] or 280) * qty weight += (r["weight_g"] or 280) * qty
lines.append({"sku": it.sku, "title": r.get("title"), "release_id": r.get("release_id"), "qty": qty, "price": price}) lines.append({"sku": it.sku, "title": r.get("title"), "release_id": r.get("release_id"), "qty": qty, "price": price})
@ -444,9 +446,30 @@ async def checkout(body: CheckoutIn, db=Depends(get_db)):
total = round(subtotal + shipping, 2) total = round(subtotal + shipping, 2)
gst = round(total / 11, 2) # AU GST-inclusive component (for the order record) gst = round(total / 11, 2) # AU GST-inclusive component (for the order record)
ref = "WEB" + datetime.now().strftime("%y%m%d%H%M%S") ref = "WEB" + datetime.now().strftime("%y%m%d%H%M%S")
# CLAIM stock atomically BEFORE charging — flip each copy sold in one statement so two concurrent
# buyers of a unique copy can't both charge. A missed claim releases the ones we already took.
# ponytail: claim-then-charge; a crash between claim and charge strands the copy sold with no sale — manual release, no sweeper. Add a sweeper if crashes get common.
claimed = []
for li in lines:
got = (await db.execute(text(
"UPDATE inventory SET in_stock=false, status='sold', sold_date=now() "
"WHERE sku=:sku AND store_id=1 AND in_stock RETURNING sku"), {"sku": li["sku"]})).first()
if got:
claimed.append(li["sku"])
else: # someone just bought it — release our partial claim, bail
if claimed:
await db.execute(text("UPDATE inventory SET in_stock=true, status='publish', sold_date=NULL "
"WHERE sku=ANY(:claimed) AND store_id=1"), {"claimed": claimed})
await db.commit()
raise HTTPException(409, f"{li['sku']} just sold")
await db.commit() # claims are locked in before any money moves
pay = await payments.charge(db, body.provider, body.token, int(round(total * 100)), "AUD", ref) pay = await payments.charge(db, body.provider, body.token, int(round(total * 100)), "AUD", ref)
if not pay.get("ok"): if not pay.get("ok"):
await db.execute(text("UPDATE inventory SET in_stock=true, status='publish', sold_date=NULL "
"WHERE sku=ANY(:claimed) AND store_id=1"), {"claimed": claimed}) # decline → back on the floor
await db.commit()
return {"ok": False, "stage": "payment", "error": pay.get("error")} return {"ok": False, "stage": "payment", "error": pay.get("error")}
try: # money has left the customer — if we can't record the order, log for manual refund/reconciliation
sn = "W" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper() sn = "W" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper()
sale_id = (await db.execute(text(""" sale_id = (await db.execute(text("""
INSERT INTO sales (sale_number, subtotal, discount_amount, tax_amount, total, status, payment_method, INSERT INTO sales (sale_number, subtotal, discount_amount, tax_amount, total, status, payment_method,
@ -459,9 +482,12 @@ async def checkout(body: CheckoutIn, db=Depends(get_db)):
VALUES (:s,:sku,:rid,:nm,:q,:up,:lt)"""), VALUES (:s,:sku,:rid,:nm,:q,:up,:lt)"""),
{"s": sale_id, "sku": li["sku"], "rid": li["release_id"], "nm": li["title"], {"s": sale_id, "sku": li["sku"], "rid": li["release_id"], "nm": li["title"],
"q": li["qty"], "up": li["price"], "lt": round(li["price"] * li["qty"], 2)}) "q": li["qty"], "up": li["price"], "lt": round(li["price"] * li["qty"], 2)})
await db.execute(text("UPDATE inventory SET in_stock=false, status='sold', sold_date=now() WHERE sku=:sku AND store_id=1"), # stock already claimed sold above (atomic claim before the charge)
{"sku": li["sku"]})
await db.commit() await db.commit()
except Exception:
log.error("checkout: PAYMENT TAKEN but order write failed — manual refund/reconcile. "
"payment_id=%s provider=%s ref=%s", pay.get("payment_id"), pay.get("provider"), ref)
raise
try: # anti-oversell: delist these from Discogs now they've sold online (best-effort) try: # anti-oversell: delist these from Discogs now they've sold online (best-effort)
from . import discogs_mp from . import discogs_mp
await discogs_mp.delist_skus(db, [li["sku"] for li in lines]) await discogs_mp.delist_skus(db, [li["sku"] for li in lines])

16
app/urlguard.py Normal file
View File

@ -0,0 +1,16 @@
import ipaddress
import socket
from urllib.parse import urlparse
def safe_public_url(url):
"""Resolve url's host and reject any that maps to a non-public IP (SSRF guard)."""
p = urlparse(url)
if p.scheme not in ("http", "https") or not p.hostname:
raise ValueError("url must be http(s) with a hostname")
for info in socket.getaddrinfo(p.hostname, None):
ip = ipaddress.ip_address(info[4][0])
if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
or ip.is_multicast or ip.is_unspecified):
raise ValueError(f"host resolves to non-public ip {ip}")
return url