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:
@@ -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
146
app/app/lib/theme.ts
Normal 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",
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user