113 lines
4.5 KiB
Python
113 lines
4.5 KiB
Python
import json
|
|
import re
|
|
from collections import Counter
|
|
from urllib.parse import urljoin
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import text
|
|
|
|
from .auth import require_token
|
|
from .db import get_db
|
|
from .urlguard import safe_public_url
|
|
|
|
# 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.
|
|
router = APIRouter(prefix="/layout", tags=["layout"])
|
|
|
|
DEFAULTS = {
|
|
"theme": {"primary": "#ff5db1", "accent": "#46d18a", "bg": "#0c0c0e",
|
|
"panel": "#141418", "text": "#f0f0f2", "font": "system-ui", "logo": "",
|
|
"radius": 12, "cardCols": 4},
|
|
"menu": [{"label": "Records", "href": "/records"}, {"label": "Wanted", "href": "/wantlist"}],
|
|
"card": ["cover", "title", "artist", "price", "condition", "cart"],
|
|
"product_page": ["cover", "title", "artist", "price", "condition", "genre",
|
|
"tracklist", "cart", "related"],
|
|
}
|
|
# Every element the editor can place, per surface.
|
|
PALETTE = {
|
|
"card": ["cover", "title", "artist", "price", "condition", "cart", "genre",
|
|
"format", "year", "stock_badge", "wishlist"],
|
|
"product_page": ["cover", "gallery", "title", "artist", "label", "price", "condition",
|
|
"genre", "format", "year", "country", "tracklist", "cart", "wishlist",
|
|
"description", "related", "dealgod_value"],
|
|
}
|
|
|
|
|
|
class ConfigIn(BaseModel):
|
|
config: dict
|
|
|
|
|
|
class UrlIn(BaseModel):
|
|
url: str
|
|
|
|
|
|
@router.get("")
|
|
async def get_layout(ident=Depends(require_token), db=Depends(get_db)):
|
|
row = (await db.execute(text("SELECT config FROM store_config WHERE store_id=:s"),
|
|
{"s": ident["store_id"]})).first()
|
|
cfg = row[0] if row else None
|
|
if isinstance(cfg, str):
|
|
cfg = json.loads(cfg)
|
|
return {"ok": True, "config": cfg or DEFAULTS, "defaults": DEFAULTS, "palette": PALETTE}
|
|
|
|
|
|
@router.post("")
|
|
async def save_layout(body: ConfigIn, ident=Depends(require_token), db=Depends(get_db)):
|
|
await db.execute(text("""
|
|
INSERT INTO store_config (store_id, config, updated_at)
|
|
VALUES (:s, CAST(:c AS jsonb), now())
|
|
ON CONFLICT (store_id) DO UPDATE SET config = EXCLUDED.config, updated_at = now()
|
|
"""), {"s": ident["store_id"], "c": json.dumps(body.config)})
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/import-brand")
|
|
async def import_brand(body: UrlIn, ident=Depends(require_token)):
|
|
"""Pull a store's brand from its website — theme colour, logo, font — the 'bam, adapt
|
|
colourway' move (same spirit as DealGod store-intel). Lightweight HTTP scrape."""
|
|
url = body.url.strip()
|
|
if not url.startswith("http"):
|
|
url = "https://" + url
|
|
try:
|
|
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:
|
|
html = (await c.get(url)).text
|
|
except Exception as e:
|
|
raise HTTPException(400, f"could not fetch {url}: {e}")
|
|
|
|
def meta(prop):
|
|
m = (re.search(rf'<meta[^>]+(?:name|property)=["\']{re.escape(prop)}["\'][^>]+content=["\']([^"\']+)', html, re.I)
|
|
or re.search(rf'<meta[^>]+content=["\']([^"\']+)["\'][^>]+(?:name|property)=["\']{re.escape(prop)}["\']', html, re.I))
|
|
return m.group(1) if m else None
|
|
|
|
theme_color = meta("theme-color")
|
|
logo = meta("og:image")
|
|
if not logo:
|
|
m = re.search(r'<link[^>]+rel=["\'][^"\']*icon[^"\']*["\'][^>]+href=["\']([^"\']+)', html, re.I)
|
|
logo = m.group(1) if m else None
|
|
if logo and not logo.startswith("http"):
|
|
logo = urljoin(url, logo)
|
|
|
|
counts = Counter(h.lower() for h in re.findall(r'#[0-9a-fA-F]{6}', html))
|
|
common = [h for h, _ in counts.most_common(24) if h not in ('#ffffff', '#000000')][:6]
|
|
|
|
gf = re.search(r'fonts\.googleapis\.com/css2?\?family=([A-Za-z0-9+]+)', html)
|
|
if gf:
|
|
font = gf.group(1).replace('+', ' ')
|
|
else:
|
|
fm = re.search(r'font-family:\s*["\']?([A-Za-z0-9 \-]+)', html, re.I)
|
|
font = fm.group(1).strip() if fm else None
|
|
|
|
return {"ok": True, "palette": {
|
|
"primary": theme_color or (common[0] if common else "#ff5db1"),
|
|
"colors": common, "logo": logo or "", "font": font or "system-ui",
|
|
}}
|