R6 L1+L3: motore di stile del form (token + CSS custom)

Il form era gia' tokenizzato (~30 var CSS in :root): non riscriviamo nulla,
iniettiamo un blocco di override DOPO il CSS di base, cosi' un errore di
configurazione del merchant non puo' rompere il default.

- theme.ts (modulo PURO, condiviso con la futura anteprima admin): ThemeTokens,
  FONT_PRESETS, themeStyle(). Sanificazione a monte: colori solo esadecimali,
  raggio e larghezza clampati, font da whitelist, CSS custom con rimozione di
  </style, @import, expression(), javascript: e cap a 4000 char.
  Derivati: --focus-ring da accent (rgba), --primary-bg-hover per shading.
- recesso.server: tokenizzati anche font (--font) e larghezza (--card-max), che
  erano hardcoded; renderShell(inner, theme) inietta l'override; renderStep1..4
  accettano e propagano il tema.
- Settings: themeAccent/ButtonBg/ButtonText/Radius/Font/Width/CustomCss + migrazione.
- proxy: loadTheme(shop) in loader e action, propagato ai 22 punti di render.
  Nell'early-return senza sessione il tema e' null (non conosciamo lo shop).

fly.toml: min_machines_running resta 0 per scelta (TODO go-live, vedi
PROTECTED-CUSTOMER-DATA.md §5.2).
This commit is contained in:
2026-07-10 13:07:43 +02:00
parent 787553af22
commit d331e609a5
6 changed files with 229 additions and 34 deletions

View File

@@ -16,6 +16,7 @@
import { createHash } from "node:crypto";
import type { AdminApiContext } from "@shopify/shopify-app-remix/server";
import type { ExclusionRule } from "@prisma/client";
import { themeStyle, type ThemeTokens } from "./theme";
import {
FIELD,
INFO_TITLE,
@@ -690,19 +691,21 @@ const PAGE_CSS = `
--shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 30px rgba(18, 24, 40, 0.08);
--radius: 14px;
--radius-sm: 9px;
--font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Inter", sans-serif;
--card-max: 520px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Inter", sans-serif;
font-family: var(--font);
line-height: 1.55;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.wrap { max-width: 520px; margin: 0 auto; padding: 32px 16px 72px; }
.wrap { max-width: var(--card-max); margin: 0 auto; padding: 32px 16px 72px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
@@ -967,7 +970,10 @@ const PAGE_CSS = `
`;
/** Wrapper documento HTML standalone. `inner` è già HTML sicuro. */
export function renderShell(inner: string): string {
export function renderShell(inner: string, theme?: ThemeTokens | null): string {
// Gli override del merchant vengono DOPO il CSS di base: se sbaglia una
// configurazione, il default resta comunque valido.
const override = themeStyle(theme);
return `<!doctype html>
<html lang="it">
<head>
@@ -976,7 +982,7 @@ export function renderShell(inner: string): string {
<meta name="color-scheme" content="light dark">
<meta name="robots" content="noindex">
<title>${escapeHtml(PAGE_TITLE)}</title>
<style>${PAGE_CSS}</style>
<style>${PAGE_CSS}</style>${override ? `\n<style>${override}</style>` : ""}
</head>
<body>
<script>(function(){if(window.self!==window.top){try{document.body.className="embed";}catch(e){}}})();</script>
@@ -1048,7 +1054,7 @@ function stepIndicator(current: number): string {
* (pattern dei modal moderni). I bottoni stanno nel footer e referenziano la form
* via attributo `form=` (HTML5), così restano sempre visibili.
*/
function stepLayout(parts: { head: string; body: string; foot?: string }): string {
function stepLayout(parts: { head: string; body: string; foot?: string }, theme?: ThemeTokens | null): string {
return `<div class="rc-head">
${parts.head}
</div>
@@ -1069,7 +1075,7 @@ export function renderStep1(opts?: {
error?: string;
orderName?: string;
email?: string;
}): string {
}, theme?: ThemeTokens | null): string {
const orderName = opts?.orderName ?? "";
const email = opts?.email ?? "";
return renderShell(
@@ -1086,6 +1092,7 @@ ${errorBanner(opts?.error)}
</form>`,
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
}),
theme,
);
}
@@ -1098,7 +1105,7 @@ export function renderStep2(data: {
statementText: string;
error?: string;
notice?: string;
}): string {
}, theme?: ThemeTokens | null): string {
const customerName = data.customerName ?? "";
return renderShell(
stepLayout({
@@ -1122,6 +1129,7 @@ export function renderStep2(data: {
</form>`,
foot: `<button type="submit" form="rcform" class="btn btn-primary">Continua</button>`,
}),
theme,
);
}
@@ -1133,7 +1141,7 @@ export function renderStep3(data: {
customerName: string;
statementText: string;
error?: string;
}): string {
}, theme?: ThemeTokens | null): string {
return renderShell(
stepLayout({
head: stepHead(3, "Controlla i dati prima di confermare."),
@@ -1169,6 +1177,7 @@ export function renderStep3(data: {
<button type="submit" form="rcconfirm" class="btn btn-primary">${escapeHtml(CONFIRM_LABEL)}</button>
</div>`,
}),
theme,
);
}
@@ -1177,7 +1186,7 @@ export function renderStep4(data: {
line1: string;
line2: string;
line3: string;
}): string {
}, theme?: ThemeTokens | null): string {
return renderShell(
stepLayout({
head: `${stepIndicator(4)}`,
@@ -1188,6 +1197,7 @@ export function renderStep4(data: {
<p>${escapeHtml(data.line3)}</p>
</div>`,
}),
theme,
);
}

146
app/app/lib/theme.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* Motore di stile del form di recesso — Livello 1 (token) e Livello 3 (CSS custom).
*
* Il form e' gia' interamente tokenizzato (`:root` in recesso.server). Qui NON
* riscriviamo il CSS: generiamo un blocco di override che viene iniettato DOPO
* quello di base. Cosi' il default resta sempre valido anche se il merchant
* sbaglia una configurazione.
*
* Modulo PURO (niente node/server): lo usa sia il render dello storefront sia
* l'anteprima nell'admin.
*
* ⚠ Tutto cio' che arriva dal merchant e' sanificato qui, non a valle.
*/
export interface ThemeTokens {
accent?: string | null; // link, focus
buttonBg?: string | null; // bottone primario
buttonText?: string | null;
radius?: number | null; // px
font?: string | null; // chiave di FONT_PRESETS
width?: number | null; // px, larghezza max della card
customCss?: string | null; // livello 3
}
export const FONT_PRESETS: Record<string, string> = {
system:
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
sans: 'Helvetica, Arial, "Helvetica Neue", sans-serif',
serif: 'Georgia, "Times New Roman", Times, serif',
mono: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
};
export const FONT_OPTIONS = [
{ label: "Di sistema (consigliato)", value: "system" },
{ label: "Sans serif", value: "sans" },
{ label: "Serif", value: "serif" },
{ label: "Monospazio", value: "mono" },
];
export const RADIUS_MIN = 0;
export const RADIUS_MAX = 32;
export const WIDTH_MIN = 360;
export const WIDTH_MAX = 900;
export const CUSTOM_CSS_MAX = 4000;
const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
/** Colore valido? Accettiamo solo esadecimali: niente url(), niente espressioni. */
export function isHexColor(v: unknown): v is string {
return typeof v === "string" && HEX.test(v.trim());
}
function expand(hex: string): [number, number, number] {
let h = hex.trim().slice(1);
if (h.length === 3) h = h[0]! + h[0]! + h[1]! + h[1]! + h[2]! + h[2]!;
return [
parseInt(h.slice(0, 2), 16),
parseInt(h.slice(2, 4), 16),
parseInt(h.slice(4, 6), 16),
];
}
/** rgba() dal colore, per l'anello di focus. */
export function hexToRgba(hex: string, alpha: number): string {
const [r, g, b] = expand(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/** Scurisce (amount<0) o schiarisce (amount>0) verso nero/bianco. */
export function shade(hex: string, amount: number): string {
const [r, g, b] = expand(hex);
const t = amount < 0 ? 0 : 255;
const p = Math.abs(amount);
const mix = (c: number) => Math.round((t - c) * p + c);
const to2 = (c: number) => mix(c).toString(16).padStart(2, "0");
return `#${to2(r)}${to2(g)}${to2(b)}`;
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
/**
* CSS custom: non possiamo permettere che il merchant esca dal blocco <style>
* o carichi risorse esterne. Niente JS possibile via CSS sui browser moderni,
* ma `</style` e `@import` vanno tolti comunque.
*/
export function sanitizeCustomCss(css: string): string {
return css
.slice(0, CUSTOM_CSS_MAX)
.replace(/<\/\s*style/gi, "")
.replace(/@import[^;]*;?/gi, "")
.replace(/expression\s*\(/gi, "")
.replace(/javascript\s*:/gi, "");
}
/**
* Blocco di override. Stringa vuota se il merchant non ha configurato nulla:
* in quel caso valgono i default del form.
*/
export function themeStyle(t?: ThemeTokens | null): string {
if (!t) return "";
const vars: string[] = [];
if (isHexColor(t.accent)) {
const a = t.accent.trim();
vars.push(`--accent: ${a};`);
vars.push(`--focus-ring: ${hexToRgba(a, 0.24)};`);
}
if (isHexColor(t.buttonBg)) {
const b = t.buttonBg.trim();
vars.push(`--primary-bg: ${b};`);
vars.push(`--primary-bg-hover: ${shade(b, -0.18)};`);
}
if (isHexColor(t.buttonText)) {
vars.push(`--primary-text: ${t.buttonText.trim()};`);
}
if (typeof t.radius === "number" && Number.isFinite(t.radius)) {
const r = clamp(Math.round(t.radius), RADIUS_MIN, RADIUS_MAX);
vars.push(`--radius: ${r}px;`);
vars.push(`--radius-sm: ${Math.max(0, r - 5)}px;`);
}
if (typeof t.width === "number" && Number.isFinite(t.width)) {
vars.push(
`--card-max: ${clamp(Math.round(t.width), WIDTH_MIN, WIDTH_MAX)}px;`,
);
}
const font = t.font && FONT_PRESETS[t.font] ? FONT_PRESETS[t.font] : null;
if (font) vars.push(`--font: ${font};`);
const root = vars.length ? `:root{${vars.join("")}}` : "";
const custom = t.customCss?.trim()
? sanitizeCustomCss(t.customCss.trim())
: "";
return `${root}${custom}`;
}
/** Anteprima admin: token di esempio quando il merchant non ha ancora salvato. */
export const THEME_DEFAULTS: Required<
Pick<ThemeTokens, "radius" | "width" | "font">
> = {
radius: 14,
width: 520,
font: "system",
};

View File

@@ -55,6 +55,24 @@ import {
sha256,
} from "../lib/recesso.server";
import type { MatchedOrder } from "../lib/recesso.server";
import type { ThemeTokens } from "../lib/theme";
/** Token di stile del merchant (livello 1 + 3). Null = default del form. */
async function loadTheme(shop: string): Promise<ThemeTokens | null> {
const s = await db.settings
.findUnique({ where: { shop } })
.catch(() => null);
if (!s) return null;
return {
accent: s.themeAccent,
buttonBg: s.themeButtonBg,
buttonText: s.themeButtonText,
radius: s.themeRadius,
font: s.themeFont,
width: s.themeWidth,
customCss: s.themeCustomCss,
};
}
/** Errore diagnosticabile ma senza PII: maschera gli indirizzi email. */
function redactErr(msg: string): string {
@@ -109,8 +127,9 @@ async function checkCompliance(
// GET /apps/recesso -> Step 1 (form di lookup).
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.public.appProxy(request);
return htmlResponse(renderStep1());
const { session } = await authenticate.public.appProxy(request);
const theme = session ? await loadTheme(session.shop) : null;
return htmlResponse(renderStep1(undefined, theme));
};
export const action = async ({ request }: ActionFunctionArgs) => {
@@ -119,9 +138,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
// Narrowing: senza sessione offline non abbiamo Admin API per lo shop.
// Multi-tenant: lo shop lo prendiamo SOLO da session.shop, mai dal client.
if (!session || !admin) {
return htmlResponse(renderStep1({ error: ERROR.generic }));
// Senza sessione non conosciamo lo shop: nessun tema da applicare.
return htmlResponse(renderStep1({ error: ERROR.generic }, null));
}
const shop = session.shop;
const theme = await loadTheme(shop);
const form = await request.formData();
const intent = String(form.get("intent") ?? "");
@@ -141,7 +162,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.missingField,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
if (!isValidEmail(emailInput)) {
@@ -150,7 +171,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.invalidEmail,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
@@ -169,7 +190,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.generic,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
@@ -191,7 +212,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: ERROR.lookupNoMatch,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
@@ -203,7 +224,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
error: block,
orderName: orderNameInput,
email: emailInput,
}),
}, theme),
);
}
@@ -218,7 +239,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
email: match.email,
statementText: statementTemplate(match.orderName),
notice: orderClosed ? NOTICE.orderClosed : undefined,
}),
}, theme),
);
}
@@ -235,7 +256,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
// Sicurezza: se mancano i riferimenti d'ordine (tamper/link diretto),
// riparti dallo Step 1 senza rivelare nulla.
if (!orderId || !orderName) {
return htmlResponse(renderStep1({ error: ERROR.generic }));
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
}
if (!customerName || !email || !statementText) {
@@ -247,7 +268,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
customerName,
statementText: statementText || statementTemplate(orderName),
error: ERROR.missingField,
}),
}, theme),
);
}
if (!isValidEmail(email)) {
@@ -259,12 +280,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
customerName,
statementText,
error: ERROR.invalidEmail,
}),
}, theme),
);
}
return htmlResponse(
renderStep3({ orderId, orderName, email, customerName, statementText }),
renderStep3({ orderId, orderName, email, customerName, statementText }, theme),
);
}
@@ -278,7 +299,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const email = String(form.get("email") ?? "").trim();
const statementText = String(form.get("statementText") ?? "").trim();
if (!orderId || !orderName) {
return htmlResponse(renderStep1({ error: ERROR.generic }));
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
}
return htmlResponse(
renderStep2({
@@ -287,7 +308,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
email,
customerName,
statementText: statementText || statementTemplate(orderName),
}),
}, theme),
);
}
@@ -310,7 +331,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
!statementText ||
!isValidEmail(email)
) {
return htmlResponse(renderStep1({ error: ERROR.generic }));
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
}
// Rate-limit ANCHE sulla conferma. Senza, l'endpoint e' un amplificatore:
@@ -324,20 +345,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
detail: orderName,
},
});
return htmlResponse(renderStep1({ error: ERROR.generic }));
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
}
// Re-verifica server-side (integrità hidden fields / anti-tamper):
// l'ordine deve ancora esistere e l'email combaciare per questo shop.
const match = await lookupOrder(admin, orderName, email);
if (!match || match.orderId !== orderId) {
return htmlResponse(renderStep1({ error: ERROR.lookupNoMatch }));
return htmlResponse(renderStep1({ error: ERROR.lookupNoMatch }, theme));
}
// A6: re-check finestra + esclusioni (anti-tamper) prima di registrare.
const block = await checkCompliance(shop, match);
if (block) {
return htmlResponse(renderStep1({ error: block }));
return htmlResponse(renderStep1({ error: block }, theme));
}
// IDEMPOTENZA: il diritto di recesso si esercita UNA volta per contratto.
@@ -391,7 +412,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
},
});
} catch {
return htmlResponse(renderStep1({ error: ERROR.generic }));
return htmlResponse(renderStep1({ error: ERROR.generic }, theme));
}
} else {
await db.auditLog.create({
@@ -520,6 +541,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return htmlResponse(
renderStep4(
duplicateMessage(match.orderName, transmittedLabel, email, resent),
theme,
),
);
}
@@ -666,11 +688,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
email,
!!receipt?.ok,
);
return htmlResponse(renderStep4(msg));
return htmlResponse(renderStep4(msg, theme));
}
default:
// Intent sconosciuto: torna allo Step 1 senza rivelare dettagli.
return htmlResponse(renderStep1());
return htmlResponse(renderStep1(undefined, theme));
}
};

View File

@@ -17,10 +17,11 @@ primary_region = "fra" # Frankfurt (EU data residency)
force_https = true
auto_stop_machines = true
auto_start_machines = true
# Una macchina sempre calda. Con 0, un cold start misurato ha richiesto ~38s:
# TODO(go-live): portare a 1. Con 0 un cold start misurato ha richiesto ~38s, e
# l'art. 54-bis pretende una funzione "sempre accessibile" e "facilmente
# utilizzabile", e 38 secondi dopo il clic sono un ostacolo. Costo: 1 macchina.
min_machines_running = 1
# utilizzabile". Rimandato per scelta (costo: 1 macchina sempre accesa).
# Vedi PROTECTED-CUSTOMER-DATA.md §5.2.
min_machines_running = 0
[[vm]]
size = "shared-cpu-1x"

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Settings" ADD COLUMN "themeAccent" TEXT,
ADD COLUMN "themeButtonBg" TEXT,
ADD COLUMN "themeButtonText" TEXT,
ADD COLUMN "themeCustomCss" TEXT,
ADD COLUMN "themeFont" TEXT,
ADD COLUMN "themeRadius" INTEGER,
ADD COLUMN "themeWidth" INTEGER;

View File

@@ -69,6 +69,14 @@ model Settings {
smtpPass String? // cifrato AES-256-GCM (mai in chiaro)
smtpSecure Boolean @default(false)
smtpFrom String?
// Motore di stile del form (livello 1 = token, livello 3 = CSS custom).
themeAccent String?
themeButtonBg String?
themeButtonText String?
themeRadius Int?
themeFont String?
themeWidth Int?
themeCustomCss String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt