Diff -- /var/www/vhosts/webstls.com/httpdocs/installation_instructions.html
Diff

/var/www/vhosts/webstls.com/httpdocs/installation_instructions.html

added on WebSTLs (webstls.com) at 2026-07-01 22:26:34

Added
+1400
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 93f66e3d332d
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>WebSTLs — Installation &amp; Configuration</title>
7 <link rel="preconnect" href="https://fonts.googleapis.com">
8 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
10 <link rel="stylesheet" href="html/assets/css/site.css">
11 <style>
12 /* ── Doc layout: 240px TOC + reading column ── */
13 .doc-shell { display: grid; grid-template-columns: 260px 1fr; max-width: 1280px; margin: 0 auto; gap: 40px; padding: 40px; }
14 .doc-toc {
15 position: sticky; top: 32px; align-self: start;
16 max-height: calc(100vh - 64px);
17 overflow-y: auto;
18 background: var(--col-surface-1);
19 border: 1px solid var(--col-border);
20 border-radius: var(--radius-lg);
21 padding: 22px;
22 }
23 .doc-toc h4 { font-size: 11px; font-weight: 700; letter-spacing: 1.8px; text-transform: uppercase; color: var(--col-text-3); margin-bottom: 14px; }
24 .doc-toc ol { counter-reset: toc; padding-left: 0; }
25 .doc-toc ol li { counter-increment: toc; list-style: none; margin: 4px 0; }
26 .doc-toc ol li::before { content: counter(toc, decimal-leading-zero) "."; color: var(--col-text-dim); font-family: var(--font-mono); font-size: 11px; margin-right: 8px; }
27 .doc-toc a { color: var(--col-text-2); font-size: 13px; transition: color var(--t-fast); }
28 .doc-toc a:hover { color: var(--col-accent-bright); }
29
30 .doc-body { min-width: 0; max-width: 880px; }
31 .doc-hero { margin-bottom: 48px; }
32 .doc-hero h1 { font-family: var(--font-display); font-size: 44px; font-weight: 700; letter-spacing: -1px; color: #fff; line-height: 1.05; }
33 .doc-hero .lead { color: var(--col-text-2); font-size: 17px; margin-top: 14px; max-width: 640px; line-height: 1.65; }
34
35 .doc-body section { margin-bottom: 56px; }
36 .doc-body h2 {
37 font-family: var(--font-display);
38 font-size: 28px; font-weight: 700;
39 color: #fff; letter-spacing: -0.5px;
40 margin-bottom: 12px;
41 padding-bottom: 12px;
42 border-bottom: 1px solid var(--col-border);
43 display: flex; align-items: baseline; gap: 14px;
44 }
45 .doc-body h2 .num {
46 font-family: var(--font-mono);
47 font-size: 13px;
48 color: var(--col-accent-bright);
49 font-weight: 600;
50 letter-spacing: 1px;
51 }
52 .doc-body h3 {
53 font-family: var(--font-display);
54 font-size: 18px; font-weight: 600;
55 color: var(--col-text); margin: 28px 0 10px;
56 }
57 .doc-body p { color: var(--col-text-2); line-height: 1.7; margin: 12px 0; }
58 .doc-body p strong { color: #fff; font-weight: 600; }
59 .doc-body ul, .doc-body ol { color: var(--col-text-2); padding-left: 22px; line-height: 1.7; }
60 .doc-body li { margin: 6px 0; }
61 .doc-body code {
62 font-family: var(--font-mono); font-size: 12.5px;
63 background: var(--col-surface-2); border: 1px solid var(--col-border);
64 padding: 1px 7px; border-radius: 5px;
65 color: var(--col-accent-bright);
66 }
67 .doc-body a { color: var(--col-accent-bright); }
68 .doc-body a:hover { text-decoration: underline; }
69
70 /* Code blocks */
71 pre {
72 background: var(--col-surface-1);
73 border: 1px solid var(--col-border);
74 border-radius: var(--radius-md);
75 padding: 16px 18px;
76 overflow-x: auto;
77 font-family: var(--font-mono); font-size: 12.5px;
78 color: #d6e1f2;
79 line-height: 1.6;
80 margin: 16px 0;
81 position: relative;
82 }
83 pre::before {
84 content: attr(data-label);
85 position: absolute; top: 8px; right: 12px;
86 font-size: 9px; font-weight: 700;
87 letter-spacing: 1.6px; text-transform: uppercase;
88 color: var(--col-text-3);
89 background: var(--col-surface-2); padding: 3px 8px;
90 border-radius: 4px; border: 1px solid var(--col-border);
91 }
92 pre code { background: none; border: none; padding: 0; color: inherit; font-size: inherit; }
93 pre .c { color: var(--col-text-3); font-style: italic; } /* comment */
94 pre .k { color: #c084fc; } /* keyword */
95 pre .s { color: #a3e635; } /* string */
96 pre .v { color: var(--col-cyan-bright); } /* var */
97 pre .o { color: var(--col-warning); } /* operator/path */
98 pre .n { color: #fbbf24; } /* number */
99 pre .p { color: var(--col-accent-bright); } /* prompt */
100
101 table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; }
102 table th, table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--col-border); }
103 table th { background: var(--col-surface-2); color: var(--col-text-3); font-weight: 600; font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase; }
104 table td { color: var(--col-text); }
105 table td:first-child { font-family: var(--font-mono); color: var(--col-accent-bright); font-size: 12px; }
106
107 /* Step badge */
108 .step-badge {
109 display: inline-flex; align-items: center; gap: 8px;
110 padding: 4px 12px;
111 background: rgba(59,130,246,0.10);
112 border: 1px solid rgba(59,130,246,0.3);
113 border-radius: 999px;
114 color: var(--col-accent-bright);
115 font-size: 11px;
116 font-weight: 700;
117 letter-spacing: 1.5px;
118 text-transform: uppercase;
119 margin-bottom: 14px;
120 }
121
122 .check-list { list-style: none; padding: 0; }
123 .check-list li {
124 display: flex; align-items: flex-start; gap: 10px;
125 padding: 8px 0;
126 }
127 .check-list li::before {
128 content: '';
129 width: 20px; height: 20px;
130 flex-shrink: 0;
131 border-radius: 50%;
132 background: rgba(34,197,94,0.18);
133 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2322c55e' stroke-width='3'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
134 background-size: 12px;
135 background-position: center;
136 background-repeat: no-repeat;
137 margin-top: 2px;
138 }
139
140 .doc-foot { margin-top: 80px; padding-top: 32px; border-top: 1px solid var(--col-border); color: var(--col-text-3); font-size: 13px; }
141
142 @media (max-width: 1000px) {
143 .doc-shell { grid-template-columns: 1fr; padding: 24px; }
144 .doc-toc { position: static; max-height: none; }
145 }
146 </style>
147</head>
148<body>
149 <!-- Lightweight top brand strip (no full app shell needed for an install doc) -->
150 <header style="border-bottom:1px solid var(--col-border);background:var(--col-bg-2);padding:18px 40px;display:flex;align-items:center;gap:14px">
151 <div class="brand-mark"><span>WS</span></div>
152 <div class="brand-text">
153 <span class="brand-name">WebSTLs</span>
154 <span class="brand-sub">Installation Guide</span>
155 </div>
156 <span class="pill brand" style="margin-left:auto">webstls.com</span>
157 </header>
158
159 <div class="doc-shell">
160 <!-- ───── Table of contents ───── -->
161 <aside class="doc-toc">
162 <h4>Contents</h4>
163 <ol>
164 <li><a href="#requirements">Requirements</a></li>
165 <li><a href="#layout">Project layout</a></li>
166 <li><a href="#deploy">Deploy the files</a></li>
167 <li><a href="#perl-modules">Install Perl modules</a></li>
168 <li><a href="#database">Set up MySQL</a></li>
169 <li><a href="#config">Configure the app</a></li>
170 <li><a href="#apache">Apache / vhost</a></li>
171 <li><a href="#permissions">File permissions</a></li>
172 <li><a href="#smoke-test">Smoke test</a></li>
173 <li><a href="#first-user">Create the first user</a></li>
174 <li><a href="#email">Transactional email (optional)</a></li>
175 <li><a href="#stripe">Stripe (optional, for billing)</a></li>
176 <li><a href="#r2">R2 / B2 file storage (optional)</a></li>
177 <li><a href="#troubleshooting">Troubleshooting</a></li>
178 <li><a href="#flags">Feature flags &amp; staged reveal</a></li>
179 </ol>
180 </aside>
181
182 <!-- ───── Body ───── -->
183 <main class="doc-body">
184
185 <div class="doc-hero">
186 <span class="step-badge"><span class="dot-status green"></span> Production install</span>
187 <h1>WebSTLs — Installation &amp; Configuration</h1>
188 <p class="lead">A step-by-step deploy guide for the unified WebSTLs Creator Platform. Target: a Linux server hosting <code>webstls.com</code> at <code>/var/www/vhosts/webstls.com/httpdocs/</code>.</p>
189 </div>
190
191 <!-- 1. REQUIREMENTS -->
192 <section id="requirements">
193 <h2><span class="num">01</span> Requirements</h2>
194 <p>WebSTLs is a modular monolith: Perl + MySQL on the back, vanilla HTML/CSS/JS on the front.</p>
195
196 <table>
197 <thead><tr><th>Component</th><th>Version</th><th>Notes</th></tr></thead>
198 <tbody>
199 <tr><td>Linux</td><td>any modern</td><td>tested on RHEL/CentOS, Debian/Ubuntu, AlmaLinux</td></tr>
200 <tr><td>Apache</td><td>2.4+</td><td>with <code>mod_cgi</code> or <code>mod_cgid</code> enabled</td></tr>
201 <tr><td>Perl</td><td>5.16+</td><td>5.30+ recommended</td></tr>
202 <tr><td>MySQL</td><td>8.0+</td><td>or MariaDB 10.5+ — schema uses <code>JSON</code> &amp; <code>INET6_ATON</code></td></tr>
203 <tr><td>OpenSSL</td><td>1.1+</td><td>required for HTTPS termination at Apache (no Perl SSL modules needed)</td></tr>
204 <tr><td>RAM</td><td>1 GB+</td><td>for a single-server pilot; scale per traffic</td></tr>
205 <tr><td>Disk</td><td>10 GB+</td><td>excluding model file storage (use R2/B2 for that)</td></tr>
206 </tbody>
207 </table>
208
209 <p>Optional but recommended: Redis (cache + background queue), Cloudflare in front (CDN + SSL for custom domains), <a href="https://www.cloudflare.com/products/r2/">Cloudflare R2</a> for model file storage.</p>
210 </section>
211
212 <!-- 2. LAYOUT -->
213 <section id="layout">
214 <h2><span class="num">02</span> Project layout</h2>
215 <p>After deploying you should see this structure under your vhost root:</p>
216
217 <pre data-label="filesystem"><code><span class="o">/var/www/vhosts/webstls.com/httpdocs/</span>
218├── <span class="o">assets/</span>
219│ ├── css/site.css
220│ ├── javascript/site.js
221│ ├── javascript/graphs.js
222│ └── images/ <span class="c"># 39 reference webp images</span>
223├── <span class="o">MODS/</span>
224│ ├── Config.pm <span class="c"># framework config (paths, db name)</span>
225│ ├── DBConnect.pm <span class="c"># MySQL DBI wrapper — fill in creds here</span>
226│ ├── Template.pm <span class="c"># [loop:] [if:] [$var] templating engine</span>
227│ ├── Login.pm <span class="c"># auth: sessions, argon2/PBKDF2 passwords</span>
228│ ├── ReadSetCookie.pm <span class="c"># cookie helper used by Login.pm</span>
229│ ├── RandCode.pm <span class="c"># alpha-numeric token generator</span>
230│ └── WebSTLs/
231│ ├── Config.pm <span class="c"># WebSTLs settings (db name, brand, urls)</span>
232│ └── Wrapper.pm <span class="c"># sidebar+topbar shell renderer</span>
233├── <span class="o">TEMPLATES/</span>
234│ ├── webstls_wrapper.html <span class="c"># sidebar+topbar shell</span>
235│ ├── webstls_marketing.html <span class="c"># logged-out shell</span>
236│ ├── webstls_dashboard.html <span class="c"># page bodies, one per .cgi</span>
237│ ├── webstls_index.html
238│ ├── webstls_login.html
239│ └── ... (13 page bodies)
240├── .htaccess <span class="c"># Apache config (Options +ExecCGI, FilesMatch, headers)</span>
241├── index.cgi <span class="c"># landing page</span>
242├── login.cgi signup.cgi logout.cgi
243├── dashboard.cgi models.cgi upload.cgi
244├── storefront.cgi analytics.cgi optimization.cgi
245├── settings.cgi admin.cgi
246├── messages.cgi audience.cgi
247└── (deploy artifacts only — no <code>db_schema.sql</code> here)
248
249<span class="c"># Plus two protective .htaccess files inside subdirectories:</span>
250MODS/.htaccess <span class="c"># Require all denied — keeps Perl source out of the web</span>
251TEMPLATES/.htaccess <span class="c"># Require all denied — same for template sources</span>
252
253<span class="c"># Lives ALONGSIDE html/ in the source bundle, not inside it:</span>
254../db_schema.sql <span class="c"># MySQL schema — load once, then keep off the server</span>
255../installation_instructions.html <span class="c"># this document — open locally, not deployed</span></code></pre>
256
257 <p><strong>Two-file pattern.</strong> Each page is a thin <code>.cgi</code> handler (~1-3 KB) that verifies the session, populates <code>$tvars</code>, and asks <code>MODS::Template</code> to render its matching <code>TEMPLATES/webstls_*.html</code> body. The body string is then handed to <code>MODS::WebSTLs::Wrapper</code>, which renders the sidebar+topbar shell around it.</p>
258 </section>
259
260 <!-- 3. DEPLOY -->
261 <section id="deploy">
262 <h2><span class="num">03</span> Deploy the files</h2>
263 <p>Upload the contents of this <code>html/</code> folder directly into <code>/var/www/vhosts/webstls.com/httpdocs/</code>. SSH+rsync is fastest:</p>
264
265 <pre data-label="bash"><code><span class="p">$</span> <span class="k">rsync</span> -avz --delete \
266 ./html/ \
267 user@webstls.com:/var/www/vhosts/webstls.com/httpdocs/</code></pre>
268
269 <p>Or via SCP / SFTP / cPanel File Manager — whatever you use. Make sure these files arrive intact:</p>
270 <ul class="check-list">
271 <li>All <code>.cgi</code> files at the root</li>
272 <li>The <code>MODS/</code> folder (with <code>WebSTLs/</code> subfolder)</li>
273 <li>The <code>TEMPLATES/</code> folder (every <code>webstls_*.html</code>)</li>
274 <li>The <code>assets/</code> folder</li>
275 <li><strong>Three <code>.htaccess</code> files</strong> — one in the webroot, one in <code>MODS/</code>, one in <code>TEMPLATES/</code></li>
276 </ul>
277
278 <p style="margin-top:14px"><strong>Don't deploy <code>db_schema.sql</code> to the webroot.</strong> It lives alongside <code>html/</code> in the source bundle, not inside it. Upload it to a one-off location like <code>/tmp/db_schema.sql</code> for the schema-load step (§5), then delete it. The <code>.htaccess</code> blocks <code>*.sql</code> from being web-served if it ever ends up in the webroot, but keeping it out entirely is cleaner.</p>
279
280 <div class="banner warning" style="margin-top:14px">
281 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
282 <div class="text-xs"><strong>WinSCP / FileZilla hide dotfiles by default.</strong> If <code>.htaccess</code> doesn't appear in your local file pane, toggle <em>Options → Preferences → Panels → Show hidden files</em> in WinSCP, or <em>Server → Force showing hidden files</em> in FileZilla. The site will 500 on every request without these three files.</div>
283 </div>
284 </section>
285
286 <!-- 4. PERL MODULES -->
287 <section id="perl-modules">
288 <h2><span class="num">04</span> Install Perl modules</h2>
289 <p>WebSTLs is deliberately light on CPAN. The minimum to run is <strong>three modules</strong> — everything else is optional with a built-in fallback.</p>
290
291 <h3>Required (just to boot)</h3>
292 <table>
293 <thead><tr><th>Module</th><th>Used by</th><th>Why</th></tr></thead>
294 <tbody>
295 <tr><td>DBI</td><td>MODS/DBConnect.pm</td><td>generic MySQL connector</td></tr>
296 <tr><td>DBD::mysql</td><td>MODS/DBConnect.pm</td><td>DBI driver for MySQL/MariaDB</td></tr>
297 <tr><td>CGI</td><td>every .cgi</td><td>request parsing &amp; cookie I/O</td></tr>
298 </tbody>
299 </table>
300
301 <pre data-label="bash"><code><span class="p">#</span> <span class="c"># RHEL / Alma / Rocky / CentOS</span>
302<span class="p">#</span> <span class="k">dnf</span> install -y perl-DBI perl-DBD-MySQL perl-CGI
303
304<span class="p">#</span> <span class="c"># Debian / Ubuntu</span>
305<span class="p">#</span> <span class="k">apt-get</span> install -y libdbi-perl libdbd-mysql-perl libcgi-pm-perl
306
307<span class="p">#</span> <span class="c"># Or via cpanm — works on any distro</span>
308<span class="p">$</span> <span class="k">cpanm</span> DBI DBD::mysql CGI</code></pre>
309
310 <h3>Optional — better passwords / faster sessions</h3>
311 <p>These are nice to have but <strong>not required</strong>. <code>MODS/Login.pm</code> detects them at startup and uses them when present, otherwise falls back gracefully.</p>
312
313 <table>
314 <thead><tr><th>Module</th><th>What it gives you</th><th>Fallback if missing</th></tr></thead>
315 <tbody>
316 <tr><td>Crypt::Argon2</td><td>Argon2id — current best-practice password hash</td><td>Salted iterated SHA-256 (100,000 rounds) — uses only <code>Digest::SHA::sha256_hex</code> which has been in Perl forever</td></tr>
317 <tr><td>Data::UUID</td><td>RFC 4122 v4 session tokens</td><td>Built-in random UUIDv4 generator (no external deps)</td></tr>
318 </tbody>
319 </table>
320
321 <pre data-label="bash"><code><span class="p">$</span> <span class="k">cpanm</span> Crypt::Argon2 Data::UUID</code></pre>
322
323 <p><strong>You can skip both</strong> and the site still runs end-to-end. Crypt::Argon2 is an XS module that needs a C compiler — locked-down shared hosting usually doesn't have one, in which case the iter-sha256 fallback kicks in. That fallback is plenty strong for most installs: 100k SHA-256 rounds with a 16-char random salt = 100ms+ per password verify, which rate-limits brute-force attempts naturally.</p>
324 <p><strong>Important about Digest::SHA versions:</strong> the <code>pbkdf2_hex</code> export was added in Digest::SHA 5.65 (2014). Older Perl installs (RHEL 7, CentOS 7) ship with earlier Digest::SHA versions. Login.pm uses only <code>sha256_hex</code> — present in every Digest::SHA version since 2003 — so it works regardless of version.</p>
325 <p>If you later install <code>Crypt::Argon2</code>, restart Apache. New signups will be hashed with Argon2id; existing accounts keep working with their iter-sha256 hash until they reset their password.</p>
326
327 <h3>Not needed</h3>
328 <p>Earlier scaffolds shipped with <code>JSON</code>, <code>LWP::UserAgent</code>, <code>IO::Socket::SSL</code>, <code>HTTP::Request</code>, and <code>Data::Dumper</code> — none of those are referenced by the WebSTLs codebase. Install them later if you add Stripe webhooks or external API calls.</p>
329
330 <h3>Verify</h3>
331 <pre data-label="bash"><code><span class="p">$</span> <span class="k">perl</span> -MDBI -MDBD::mysql -MCGI -e <span class="s">'print "core deps ok\n"'</span>
332<span class="p">$</span> <span class="k">perl</span> -e <span class="s">'eval { require Crypt::Argon2 } and print "Argon2 ok (best)\n" or print "PBKDF2 fallback (fine)\n"'</span></code></pre>
333 </section>
334
335 <!-- 5. DATABASE -->
336 <section id="database">
337 <h2><span class="num">05</span> Set up MySQL / MariaDB</h2>
338
339 <div class="banner warning" style="margin-bottom:18px">
340 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
341 <div class="text-xs"><strong>Database names are case-sensitive on Linux MariaDB / MySQL.</strong> If Plesk creates your DB as <code>WEBSTLs</code> (mixed case), then <code>WEBSTLS</code> is a different database that doesn't exist. Whatever case you use when creating the DB must match exactly in <code>MODS/DBConnect.pm</code> and <code>MODS/WebSTLs/Config.pm</code>.</div>
342 </div>
343
344 <h3>5.1 — Plesk users (recommended path)</h3>
345 <p>In the Plesk panel: <strong>Websites &amp; Domains → webstls.com → Databases → Add Database</strong>. Plesk creates the database AND its dedicated user in one step, with proper grants. Pick a database name like <code>WEBSTLs</code> (note the mixed case) — write it down exactly, you'll need it later.</p>
346 <p>Plesk creates the user with a wildcard host (<code>'WEBSTLS'@'%'</code>), which is correct for both local and remote app servers. You don't need to add a separate <code>@'localhost'</code> grant.</p>
347
348 <h3>5.2 — Bare MariaDB users (non-Plesk)</h3>
349 <p>If you're not on Plesk, create the database and user by hand. Use BACKTICKS around the DB name to lock in the exact case you intend:</p>
350
351 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">CREATE DATABASE</span> `WEBSTLs` <span class="k">CHARACTER SET</span> utf8mb4;
352
353<span class="p">mysql&gt;</span> <span class="k">CREATE USER</span> <span class="s">'webstls'@'localhost'</span> <span class="k">IDENTIFIED BY</span> <span class="s">'CHANGE_ME_STRONG_PASSWORD'</span>;
354
355<span class="p">mysql&gt;</span> <span class="k">GRANT</span> SELECT, INSERT, UPDATE, DELETE,
356 CREATE, ALTER, INDEX, REFERENCES, EXECUTE,
357 TRIGGER
358 <span class="k">ON</span> `WEBSTLs`.* <span class="k">TO</span> <span class="s">'webstls'@'localhost'</span>;
359
360<span class="p">mysql&gt;</span> <span class="k">FLUSH PRIVILEGES</span>;</code></pre>
361
362 <p><code>TRIGGER</code> is in there so the schema's BEFORE INSERT / BEFORE UPDATE triggers will fire from the app's connection. Without it, INSERTs into tables that need triggers will fail.</p>
363
364 <h3>5.3 — Load the schema</h3>
365 <pre data-label="bash"><code><span class="c"># Upload db_schema.sql to a one-off location first (NOT the webroot):</span>
366<span class="p">$</span> <span class="k">scp</span> ./db_schema.sql user@webstls.com:/tmp/
367
368<span class="c"># Then load it:</span>
369<span class="p">$</span> <span class="k">mysql</span> -u webstls -p WEBSTLs &lt; /tmp/db_schema.sql
370
371<span class="c"># Clean up — schema load is one-shot:</span>
372<span class="p">$</span> <span class="k">rm</span> /tmp/db_schema.sql</code></pre>
373
374 <p>This creates 70 tables, 39 triggers, and seeds: 9 <code>platform_health</code> rows, 6 <code>system_components</code>, 10 <code>feature_flags</code>, 36 <code>storefront_themes</code>, 4 <code>billing_plans</code> (one row per tier, with monthly price + yearly-discount config in the same row) and their entitlement rows in <code>plan_features</code>, 9 <code>platform_settings</code> SEO defaults. Includes visitor-tracking + chat tables (<code>tracking_sessions</code>, <code>tracking_events</code>, <code>support_chats</code>, <code>support_chat_messages</code>, <code>support_push_links</code>) and the new <code>storefront_custom_themes</code> table for creator-built color palettes.</p>
375
376 <p>You may see <strong>~165 warnings</strong> — those are mostly <code>DROP TABLE/TRIGGER IF EXISTS</code> against non-existent objects on a fresh database. They're benign. Verify success with:</p>
377
378 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">USE</span> `WEBSTLs`;
379<span class="p">mysql&gt;</span> <span class="k">SELECT</span> COUNT(*) <span class="k">FROM</span> information_schema.tables <span class="k">WHERE</span> table_schema=<span class="s">'WEBSTLs'</span>;
380<span class="c">-- expect 63</span>
381<span class="p">mysql&gt;</span> <span class="k">SELECT</span> COUNT(*) <span class="k">FROM</span> information_schema.triggers <span class="k">WHERE</span> trigger_schema=<span class="s">'WEBSTLs'</span>;
382<span class="c">-- expect 39</span>
383<span class="p">mysql&gt;</span> <span class="k">SELECT</span> flag_key, default_value <span class="k">FROM</span> feature_flags;
384<span class="p">mysql&gt;</span> <span class="k">SELECT</span> tier, cadence, price_cents <span class="k">FROM</span> billing_plans <span class="k">ORDER BY</span> sort_order;
385<span class="c">-- expect 8 rows: free/starter/pro/studio x monthly/annual</span>
386<span class="p">mysql&gt;</span> <span class="k">DESCRIBE</span> promotions;
387<span class="c">-- 23 columns including scope, kind, code, public_slug, starts_at, ends_at, max_redemptions</span>
388<span class="p">mysql&gt;</span> <span class="k">SELECT</span> setting_key <span class="k">FROM</span> platform_settings <span class="k">WHERE</span> setting_key <span class="k">LIKE</span> <span class="s">'seo.%'</span>;
389<span class="c">-- expect 9 rows: seo.title, seo.description, seo.keywords, seo.og_image, ...</span></code></pre>
390
391 <h3>5.4 — Storefront design system (layouts + color themes)</h3>
392 <p>Each storefront has both a <strong>layout</strong> (page structure) and a <strong>color theme</strong> (palette). These are decoupled — any layout works with any theme. Both are stored on the <code>storefronts</code> row:</p>
393 <ul>
394 <li><code>storefronts.layout_id</code> — references <code>MODS::WebSTLs::Layouts</code> (36 layouts shipped). Defaults to <strong>1</strong> (Catalog Classic).</li>
395 <li><code>storefronts.theme_id</code> — references <code>MODS::WebSTLs::Themes</code> (36 themes shipped). Defaults to <strong>1</strong> (Dungeon Forge).</li>
396 <li><code>storefronts.custom_theme_id</code> — optional override. When non-NULL, points at a row in <code>storefront_custom_themes</code> (creator-built palette). The buyer-facing renderer uses <code>resolve_for_storefront()</code> which prefers <code>custom_theme_id</code> over <code>theme_id</code>.</li>
397 </ul>
398 <p>That's <strong>36 layouts &times; 36 color themes = 1,296 design combinations</strong> a creator can pick from out of the box, plus any number of custom themes they build themselves via the in-app color picker. Pickers paginate at 12 per page.</p>
399 <p>Built-in layouts and themes are <em>Perl-defined</em>, not DB-defined. To add a new layout:</p>
400 <ol>
401 <li>Drop a new template file in <code>html/TEMPLATES/store_layouts/&lt;slug&gt;.html</code>.</li>
402 <li>Add a small hash to <code>@LAYOUTS</code> in <code>html/MODS/WebSTLs/Layouts.pm</code>.</li>
403 <li>Done. The picker shows it instantly. No DB migration.</li>
404 </ol>
405 <p>To add a color theme: edit <code>html/MODS/WebSTLs/Themes.pm</code>, add a hash with the new colors, save. Same — no migration needed.</p>
406 <p>The <strong>storefront_themes</strong> table is kept around (referenced by <code>template_performance</code> for analytics) but is not load-bearing — it just holds metadata. Validation happens in Perl.</p>
407
408 <h4>Custom (creator-built) themes</h4>
409 <p>Creators can build their own palette in the Storefront editor with a 13-input color picker (Accent, Backgrounds, Borders, Text). The colors land in <strong>storefront_custom_themes</strong>:</p>
410 <ul>
411 <li>One row per saved custom theme. A storefront can have multiple saved themes and switch between them.</li>
412 <li>14 color fields: <code>bg</code>, <code>bg_2</code>, <code>surface</code>, <code>surface_2</code>, <code>cborder</code>, <code>cborder_2</code>, <code>ctext</code>, <code>ctext_2</code>, <code>ctext_3</code>, <code>accent</code>, <code>accent_2</code>, <code>accent_deep</code>, <code>accent_glow</code>, plus <code>name</code>.</li>
413 <li>Column names <code>cborder*</code> and <code>ctext*</code> are prefixed because <code>border</code> and <code>text</code> are reserved words in standard SQL. Perl maps them back to the canonical theme-hash keys (<code>border</code>, <code>border_2</code>, <code>text</code>, ...) in <code>Themes::custom_by_id()</code>.</li>
414 <li>Applying a custom theme sets <code>storefronts.custom_theme_id</code> to the row's id. Switching back to a built-in clears <code>custom_theme_id</code> to NULL.</li>
415 <li><code>accent_glow</code> is auto-derived from <code>accent</code> at 35% alpha (the builder fills it in via JS so creators don't have to think about it).</li>
416 </ul>
417
418 <h4>Migrating an existing database</h4>
419 <p>If you ran an older version of <code>db_schema.sql</code>, apply these to bring the storefronts table forward:</p>
420 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">USE</span> `WEBSTLs`;
421<span class="c">-- 5.4a: layout column (older schemas only had theme_id)</span>
422<span class="p">mysql&gt;</span> <span class="k">ALTER</span> <span class="k">TABLE</span> storefronts <span class="k">ADD COLUMN</span> layout_id BIGINT UNSIGNED NULL <span class="k">DEFAULT</span> 1 <span class="k">AFTER</span> theme_id;
423<span class="c">-- 5.4b: custom-theme override column</span>
424<span class="p">mysql&gt;</span> <span class="k">ALTER</span> <span class="k">TABLE</span> storefronts <span class="k">ADD COLUMN</span> custom_theme_id BIGINT UNSIGNED NULL <span class="k">DEFAULT</span> <span class="k">NULL</span> <span class="k">AFTER</span> theme_id;
425<span class="c">-- 5.4c: custom themes table (creator-built palettes saved per storefront)</span>
426<span class="p">mysql&gt;</span> <span class="k">CREATE</span> <span class="k">TABLE</span> <span class="k">IF</span> <span class="k">NOT</span> <span class="k">EXISTS</span> storefront_custom_themes (
427 id BIGINT UNSIGNED <span class="k">NOT</span> <span class="k">NULL</span> AUTO_INCREMENT,
428 storefront_id BIGINT UNSIGNED <span class="k">NOT</span> <span class="k">NULL</span>,
429 name VARCHAR(120) <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">DEFAULT</span> <span class="s">'My Custom Theme'</span>,
430 bg VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>,
431 bg_2 VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>,
432 surface VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>,
433 surface_2 VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>,
434 cborder VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>,
435 cborder_2 VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>,
436 ctext VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>,
437 ctext_2 VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>,
438 ctext_3 VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>,
439 accent VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>,
440 accent_2 VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>,
441 accent_deep VARCHAR(32) <span class="k">NOT</span> <span class="k">NULL</span>,
442 accent_glow VARCHAR(48) <span class="k">NOT</span> <span class="k">NULL</span>,
443 created_at DATETIME <span class="k">NOT</span> <span class="k">NULL</span>,
444 updated_at DATETIME <span class="k">NOT</span> <span class="k">NULL</span>,
445 <span class="k">PRIMARY KEY</span> (id),
446 <span class="k">KEY</span> idx_sct_storefront (storefront_id),
447 <span class="k">CONSTRAINT</span> fk_sct_storefront <span class="k">FOREIGN KEY</span> (storefront_id) <span class="k">REFERENCES</span> storefronts (id) <span class="k">ON DELETE CASCADE</span>
448) ENGINE=InnoDB <span class="k">DEFAULT</span> CHARSET=utf8mb4;
449<span class="c">-- Existing storefronts continue to use built-in theme_id until a creator builds + applies a custom theme.</span></code></pre>
450
451 <h3>5.5 — Page editor (rich-text WYSIWYG)</h3>
452 <p>The page editor (<code>page.cgi</code>) is a custom-built WYSIWYG &mdash; <strong>vanilla HTML / CSS / JS only</strong>, no third-party libraries, no CDN dependencies, no build step. Creators get headings, bold/italic/underline/strike, ordered &amp; bullet lists, links, blockquote, code blocks, images, and clear-formatting. Drag-and-drop and clipboard paste both work for image uploads.</p>
453
454 <p>Implementation: a <code>contenteditable</code> div + the standard <code>document.execCommand</code> API + the Selection/Range API for the image fallback path. The editor lives entirely inside <code>TEMPLATES/webstls_page_edit.html</code>.</p>
455
456 <h4>How image uploads work</h4>
457 <ol>
458 <li>Editor sends file to <code>upload_image.cgi</code>.</li>
459 <li>CGI authenticates, validates by magic bytes (JPG/PNG/GIF/WebP only, &le;10MB), generates a 16-char hex id, saves to <code>httpdocs/uploads/&lt;storefront_id&gt;/&lt;id&gt;.&lt;ext&gt;</code>, and INSERTs a <code>page_images</code> row.</li>
460 <li>Returns <code>{success:1, url:"/img.cgi?id=&lt;id&gt;"}</code>.</li>
461 <li>Editor inserts <code>&lt;img src="/img.cgi?id=..."&gt;</code> into the page body.</li>
462 <li>When a buyer visits the page, their browser hits <code>img.cgi?id=...</code>, which looks up the row, resolves the path, and streams bytes with the right <code>Content-Type</code>.</li>
463 </ol>
464 <p><strong>Direct file paths never leak.</strong> The on-disk location is only known to <code>img.cgi</code>; clients only ever see token URLs. The <code>/uploads/</code> directory has a <code>.htaccess</code> that blocks all direct HTTP access (<code>Require all denied</code>) as defense in depth, while the CGI continues to read files via the filesystem.</p>
465
466 <h4>One-time setup on the server</h4>
467 <pre data-label="bash"><code><span class="c"># Create the per-storefront uploads root + drop the .htaccess</span>
468<span class="p">$</span> <span class="k">mkdir -p</span> /var/www/vhosts/webstls.com/httpdocs/uploads
469<span class="p">$</span> <span class="k">chown</span> stlsweb:psacln /var/www/vhosts/webstls.com/httpdocs/uploads
470<span class="p">$</span> <span class="k">chmod</span> 755 /var/www/vhosts/webstls.com/httpdocs/uploads
471
472<span class="c"># Upload uploads/.htaccess (in the repo) so direct access is denied</span>
473<span class="c"># Then make sure both CGIs are executable</span>
474<span class="p">$</span> <span class="k">chmod</span> 755 /var/www/vhosts/webstls.com/httpdocs/upload_image.cgi
475<span class="p">$</span> <span class="k">chmod</span> 755 /var/www/vhosts/webstls.com/httpdocs/img.cgi</code></pre>
476 <p>The directory must be writable by the user Apache runs CGI scripts as (<code>stlsweb</code> on this Plesk install). Per-storefront subdirs (<code>/uploads/1/</code>, <code>/uploads/2/</code>, &hellip;) are created automatically by <code>upload_image.cgi</code> on the first upload for each storefront.</p>
477
478 <h4>Editable storefront fields</h4>
479 <p>The page editor lets the creator click anywhere on the storefront chrome to edit it in place. The fields that are click-to-edit:</p>
480 <ul>
481 <li><code>storefronts.name</code> &mdash; store name (already existed)</li>
482 <li><code>storefronts.tagline</code> &mdash; short tagline shown in hero / about</li>
483 <li><code>storefronts.about_text</code> &mdash; longer about copy</li>
484 <li><code>storefronts.hero_image_1 / _2 / _3</code> &mdash; editable hero image slots used by Catalog (3), Lookbook (1) and Storyteller (1) layouts. Other layouts use product images dynamically.</li>
485 <li><code>storefronts.hero_image_1_pos / _2_pos / _3_pos</code> &mdash; CSS <code>background-position</code> for each hero (e.g. <code>"30% 70%"</code>). Empty defaults to <code>"50% 50%"</code> (center). Set by the click-and-drag focal-point picker on each image in the page editor.</li>
486 <li><code>storefronts.cta_primary_label / cta_secondary_label</code> &mdash; the two main call-to-action labels on the hero (defaults: <code>"Get it now"</code>, <code>"See more designs"</code>).</li>
487 <li><code>storefronts.featured_badge_label</code> &mdash; small uppercase pill above the hero title (default: <code>"Featured release"</code>).</li>
488 <li><code>storefronts.section_heading</code> &mdash; heading above the secondary product strip (default: <code>"More from the studio"</code>).</li>
489 <li><code>storefronts.footer_byline</code> &mdash; right-side text in the footer after the store name (default: <code>"powered by webstls.com"</code>).</li>
490 </ul>
491 <p>Save handler is <code>update_storefront_field.cgi</code>. It whitelists field names server-side, caps lengths, and verifies the user owns the storefront before writing. Text wrapping with edit markers happens centrally in <code>store.cgi</code>, so adding a new editable text field automatically lights up on every layout that uses that tvar.</p>
492
493 <h4>Migration for storefront fields (existing database)</h4>
494 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">USE</span> `WEBSTLs`;
495<span class="p">mysql&gt;</span> <span class="k">ALTER</span> <span class="k">TABLE</span> storefronts
496 <span class="k">ADD COLUMN</span> tagline VARCHAR(255) <span class="k">NULL</span> <span class="k">AFTER</span> name,
497 <span class="k">ADD COLUMN</span> about_text TEXT <span class="k">NULL</span> <span class="k">AFTER</span> tagline,
498 <span class="k">ADD COLUMN</span> hero_image_1 VARCHAR(255) <span class="k">NULL</span> <span class="k">AFTER</span> about_text,
499 <span class="k">ADD COLUMN</span> hero_image_2 VARCHAR(255) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_1,
500 <span class="k">ADD COLUMN</span> hero_image_3 VARCHAR(255) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_2,
501 <span class="k">ADD COLUMN</span> hero_image_1_pos VARCHAR(20) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_1,
502 <span class="k">ADD COLUMN</span> hero_image_2_pos VARCHAR(20) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_2,
503 <span class="k">ADD COLUMN</span> hero_image_3_pos VARCHAR(20) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_3,
504 <span class="k">ADD COLUMN</span> cta_primary_label VARCHAR(60) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_3_pos,
505 <span class="k">ADD COLUMN</span> cta_secondary_label VARCHAR(60) <span class="k">NULL</span> <span class="k">AFTER</span> cta_primary_label,
506 <span class="k">ADD COLUMN</span> featured_badge_label VARCHAR(60) <span class="k">NULL</span> <span class="k">AFTER</span> cta_secondary_label,
507 <span class="k">ADD COLUMN</span> section_heading VARCHAR(120) <span class="k">NULL</span> <span class="k">AFTER</span> featured_badge_label,
508 <span class="k">ADD COLUMN</span> footer_byline VARCHAR(160) <span class="k">NULL</span> <span class="k">AFTER</span> section_heading,
509 <span class="k">ADD COLUMN</span> featured_title VARCHAR(200) <span class="k">NULL</span> <span class="k">AFTER</span> footer_byline,
510 <span class="k">ADD COLUMN</span> featured_price VARCHAR(40) <span class="k">NULL</span> <span class="k">AFTER</span> featured_title,
511 <span class="k">ADD COLUMN</span> featured_hero_image VARCHAR(255) <span class="k">NULL</span> <span class="k">AFTER</span> featured_price,
512 <span class="k">ADD COLUMN</span> featured_hero_pos VARCHAR(20) <span class="k">NULL</span> <span class="k">AFTER</span> featured_hero_image;</code></pre>
513
514 <p>The <code>featured_*</code> columns are <strong>storefront-level overrides</strong> for the Spotlight layout's hero content. When set, they take priority over the auto-selected featured product. This lets a creator customize the spotlight (title, price, hero image, focal point) before uploading any real models, or override the auto-pick after they have a catalog. Edited in-place via <code>/update_storefront_field.cgi</code>.</p>
515
516 <h4>Migration (page images table, existing database)</h4>
517 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">USE</span> `WEBSTLs`;
518<span class="p">mysql&gt;</span> <span class="k">CREATE</span> <span class="k">TABLE</span> page_images (
519 id VARCHAR(32) <span class="k">NOT NULL</span>,
520 user_id BIGINT UNSIGNED <span class="k">NOT NULL</span>,
521 storefront_id BIGINT UNSIGNED <span class="k">NOT NULL</span>,
522 filename VARCHAR(191) <span class="k">NOT NULL</span>,
523 mime_type VARCHAR(50) <span class="k">NOT NULL</span>,
524 size_bytes BIGINT UNSIGNED <span class="k">NOT NULL</span>,
525 created_at TIMESTAMP <span class="k">NULL DEFAULT NULL</span>,
526 <span class="k">PRIMARY KEY</span> (id),
527 <span class="k">KEY</span> idx_pi_user (user_id),
528 <span class="k">KEY</span> idx_pi_storefront (storefront_id),
529 <span class="k">CONSTRAINT</span> fk_pi_user <span class="k">FOREIGN KEY</span> (user_id) <span class="k">REFERENCES</span> users (id) <span class="k">ON DELETE CASCADE</span>,
530 <span class="k">CONSTRAINT</span> fk_pi_storefront <span class="k">FOREIGN KEY</span> (storefront_id) <span class="k">REFERENCES</span> storefronts (id) <span class="k">ON DELETE CASCADE</span>
531 ) <span class="k">ENGINE</span>=InnoDB <span class="k">DEFAULT CHARSET</span>=utf8mb4 <span class="k">ROW_FORMAT</span>=DYNAMIC;
532<span class="p">mysql&gt;</span> <span class="k">CREATE TRIGGER</span> tr_page_images_created_at <span class="k">BEFORE INSERT ON</span> page_images
533 <span class="k">FOR EACH ROW SET</span> NEW.created_at = COALESCE(NEW.created_at, NOW());</code></pre>
534
535 <h4>Cleanup</h4>
536 <p>If a creator removes an image from a page body, the on-disk file and the <code>page_images</code> row both remain. A periodic reaper (cron) that scans page bodies for live <code>/img.cgi?id=</code> references and deletes orphaned rows + files is on the roadmap. Disk usage will grow slowly until that lands.</p>
537
538 <h3>5.6 — Marketplace integrations (multi-session roadmap)</h3>
539 <p>Creators can list each design on outside marketplaces (Etsy, MyMiniFactory, Cults3D, etc.) alongside their primary webstls.com store. The framework lives in <code>MODS::WebSTLs::Marketplaces</code> + the <code>/marketplaces.cgi</code> dashboard. Each per-platform adapter ships as its own session.</p>
540
541 <h4>Available today</h4>
542 <ul>
543 <li><strong>Manual export</strong> &mdash; <code>marketplace_export.cgi?model_id=N</code>. One page with every field a marketplace listing needs, plus copy-to-clipboard and download links. Works for every platform regardless of API status. The creator pastes into Etsy/MMF/wherever manually.</li>
544 </ul>
545
546 <h4>Roadmap (live API adapters)</h4>
547 <ol>
548 <li><strong>Etsy</strong> &mdash; biggest STL marketplace, native digital download support, mature OAuth 2.0 + REST API. <a href="https://developers.etsy.com/" target="_blank">developers.etsy.com</a></li>
549 <li><strong>Gumroad</strong> &mdash; simplest API, low setup friction. <a href="https://help.gumroad.com/article/280-create-product-api" target="_blank">Gumroad product API</a></li>
550 <li><strong>MyMiniFactory</strong> &mdash; tabletop minis hub. <a href="https://www.myminifactory.com/developers" target="_blank">MMF developers</a></li>
551 <li><strong>Thangs</strong> &mdash; search-first 3D marketplace with creator Memberships and single-purchase downloads. 0% fee on Memberships, ~12% on marketplace sales. <a href="https://thangs.com/developers" target="_blank">Thangs developers</a></li>
552 <li><strong>Cults3D, Printables, CGTrader, Thingiverse</strong> &mdash; 3D-native marketplaces.</li>
553 <li><strong>Patreon</strong> &mdash; different shape (subscription tiers), built last. <a href="https://docs.patreon.com" target="_blank">Patreon API docs</a></li>
554 <li><strong>Amazon SP-API, Walmart Marketplace</strong> &mdash; only if volume justifies; multi-week approval per platform.</li>
555 </ol>
556
557 <h4>Each new adapter needs (per platform)</h4>
558 <ul>
559 <li>A registered developer account on that platform (you, the user, must create it &mdash; webstls.com can't sign up for Etsy on your behalf)</li>
560 <li>Approved API credentials (client ID + secret, or API key)</li>
561 <li>OAuth redirect URI registered: <code>https://webstls.com/marketplace_oauth.cgi?platform=&lt;slug&gt;</code></li>
562 <li>A new module at <code>MODS/WebSTLs/Marketplaces/&lt;Slug&gt;.pm</code> implementing the adapter interface</li>
563 </ul>
564
565 <h4>DB migration (existing database)</h4>
566 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">USE</span> `WEBSTLs`;
567
568<span class="c">-- Per-user platform credentials. Holds OAuth tokens when adapters are wired up.</span>
569<span class="p">mysql&gt;</span> <span class="k">CREATE</span> <span class="k">TABLE</span> marketplace_accounts (
570 id BIGINT UNSIGNED <span class="k">NOT NULL AUTO_INCREMENT</span>,
571 user_id BIGINT UNSIGNED <span class="k">NOT NULL</span>,
572 platform VARCHAR(32) <span class="k">NOT NULL</span>,
573 status <span class="k">ENUM</span>(<span class="s">'disconnected'</span>,<span class="s">'connected'</span>,<span class="s">'expired'</span>,<span class="s">'error'</span>) <span class="k">NOT NULL DEFAULT</span> <span class="s">'disconnected'</span>,
574 access_token TEXT <span class="k">NULL</span>,
575 refresh_token TEXT <span class="k">NULL</span>,
576 token_expires_at TIMESTAMP <span class="k">NULL DEFAULT NULL</span>,
577 account_handle VARCHAR(191) <span class="k">NULL</span>,
578 metadata LONGTEXT <span class="k">NULL</span>,
579 last_error VARCHAR(500) <span class="k">NULL</span>,
580 connected_at TIMESTAMP <span class="k">NULL DEFAULT NULL</span>,
581 last_used_at TIMESTAMP <span class="k">NULL DEFAULT NULL</span>,
582 created_at TIMESTAMP <span class="k">NULL DEFAULT NULL</span>,
583 updated_at TIMESTAMP <span class="k">NOT NULL DEFAULT CURRENT_TIMESTAMP</span>,
584 <span class="k">PRIMARY KEY</span> (id),
585 <span class="k">UNIQUE KEY</span> uq_mp_user_platform (user_id, platform),
586 <span class="k">KEY</span> idx_mp_status (status),
587 <span class="k">CONSTRAINT</span> fk_mp_user <span class="k">FOREIGN KEY</span> (user_id) <span class="k">REFERENCES</span> users (id) <span class="k">ON DELETE CASCADE</span>
588 ) <span class="k">ENGINE</span>=InnoDB <span class="k">DEFAULT CHARSET</span>=utf8mb4 <span class="k">ROW_FORMAT</span>=DYNAMIC;
589
590<span class="c">-- Push history per (model, platform). Status + remote listing id.</span>
591<span class="p">mysql&gt;</span> <span class="k">CREATE</span> <span class="k">TABLE</span> marketplace_pushes (
592 id BIGINT UNSIGNED <span class="k">NOT NULL AUTO_INCREMENT</span>,
593 user_id BIGINT UNSIGNED <span class="k">NOT NULL</span>,
594 model_id BIGINT UNSIGNED <span class="k">NOT NULL</span>,
595 platform VARCHAR(32) <span class="k">NOT NULL</span>,
596 status <span class="k">ENUM</span>(<span class="s">'pending'</span>,<span class="s">'success'</span>,<span class="s">'failed'</span>) <span class="k">NOT NULL DEFAULT</span> <span class="s">'pending'</span>,
597 remote_listing_id VARCHAR(191) <span class="k">NULL</span>,
598 remote_url VARCHAR(500) <span class="k">NULL</span>,
599 error_message TEXT <span class="k">NULL</span>,
600 request_payload LONGTEXT <span class="k">NULL</span>,
601 response_payload LONGTEXT <span class="k">NULL</span>,
602 created_at TIMESTAMP <span class="k">NULL DEFAULT NULL</span>,
603 <span class="k">PRIMARY KEY</span> (id),
604 <span class="k">KEY</span> idx_mp_push_user (user_id),
605 <span class="k">KEY</span> idx_mp_push_model (model_id),
606 <span class="k">KEY</span> idx_mp_push_platform (platform),
607 <span class="k">CONSTRAINT</span> fk_mp_push_user <span class="k">FOREIGN KEY</span> (user_id) <span class="k">REFERENCES</span> users (id) <span class="k">ON DELETE CASCADE</span>,
608 <span class="k">CONSTRAINT</span> fk_mp_push_model <span class="k">FOREIGN KEY</span> (model_id) <span class="k">REFERENCES</span> models (id) <span class="k">ON DELETE CASCADE</span>
609 ) <span class="k">ENGINE</span>=InnoDB <span class="k">DEFAULT CHARSET</span>=utf8mb4 <span class="k">ROW_FORMAT</span>=DYNAMIC;
610
611<span class="p">mysql&gt;</span> <span class="k">CREATE TRIGGER</span> tr_marketplace_accounts_created_at <span class="k">BEFORE INSERT ON</span> marketplace_accounts
612 <span class="k">FOR EACH ROW SET</span> NEW.created_at = COALESCE(NEW.created_at, NOW());
613<span class="p">mysql&gt;</span> <span class="k">CREATE TRIGGER</span> tr_marketplace_pushes_created_at <span class="k">BEFORE INSERT ON</span> marketplace_pushes
614 <span class="k">FOR EACH ROW SET</span> NEW.created_at = COALESCE(NEW.created_at, NOW());
615
616<span class="c">-- Marketplace -> WebSTLs import: provenance columns on `models` so the</span>
617<span class="c">-- "Pull in your models" button can dedup creations across re-runs.</span>
618<span class="c">-- Native uploads leave all three NULL. Fresh installs from the current</span>
619<span class="c">-- db_schema.sql already include them.</span>
620<span class="p">mysql&gt;</span> <span class="k">ALTER TABLE</span> models
621 <span class="k">ADD COLUMN</span> source_platform <span class="k">VARCHAR</span>(32) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_pos,
622 <span class="k">ADD COLUMN</span> source_external_id <span class="k">VARCHAR</span>(191) <span class="k">NULL</span> <span class="k">AFTER</span> source_platform,
623 <span class="k">ADD COLUMN</span> source_url <span class="k">VARCHAR</span>(500) <span class="k">NULL</span> <span class="k">AFTER</span> source_external_id,
624 <span class="k">ADD KEY</span> idx_models_source (user_id, source_platform, source_external_id);</code></pre>
625
626 <h3>5.7 — Upload page model fields</h3>
627 <p>The full upload form (<code>/upload_model.cgi</code>) writes a dozen extra fields onto each new model: tagline, visibility, scheduled release, highlights, included items, video links, specs, print settings, printer compatibility, materials, file options, and tested printers. If you've already created the <code>models</code> table from an older revision, run the ALTER below; fresh installs from the current <code>db_schema.sql</code> already include them.</p>
628
629 <pre data-label="ALTER existing models table"><code><span class="p">mysql&gt;</span> <span class="k">ALTER TABLE</span> models
630 <span class="k">ADD COLUMN</span> tagline <span class="k">VARCHAR</span>(180) <span class="k">NULL</span> <span class="k">AFTER</span> slug,
631 <span class="k">ADD COLUMN</span> visibility <span class="k">ENUM</span>(<span class="s">'public'</span>,<span class="s">'unlisted'</span>,<span class="s">'private'</span>) <span class="k">NOT NULL DEFAULT</span> <span class="s">'public'</span> <span class="k">AFTER</span> status,
632 <span class="k">ADD COLUMN</span> scheduled_publish_at <span class="k">TIMESTAMP NULL DEFAULT NULL</span> <span class="k">AFTER</span> visibility,
633 <span class="k">ADD COLUMN</span> highlights <span class="k">MEDIUMTEXT NULL</span> <span class="k">AFTER</span> scheduled_publish_at,
634 <span class="k">ADD COLUMN</span> included_items <span class="k">MEDIUMTEXT NULL</span> <span class="k">AFTER</span> highlights,
635 <span class="k">ADD COLUMN</span> video_links <span class="k">MEDIUMTEXT NULL</span> <span class="k">AFTER</span> included_items,
636 <span class="k">ADD COLUMN</span> specs_json <span class="k">MEDIUMTEXT NULL</span> <span class="k">AFTER</span> video_links,
637 <span class="k">ADD COLUMN</span> print_settings_json <span class="k">MEDIUMTEXT NULL</span> <span class="k">AFTER</span> specs_json,
638 <span class="k">ADD COLUMN</span> printer_compat <span class="k">VARCHAR</span>(255) <span class="k">NULL</span> <span class="k">AFTER</span> print_settings_json,
639 <span class="k">ADD COLUMN</span> materials <span class="k">VARCHAR</span>(255) <span class="k">NULL</span> <span class="k">AFTER</span> printer_compat,
640 <span class="k">ADD COLUMN</span> file_options <span class="k">VARCHAR</span>(255) <span class="k">NULL</span> <span class="k">AFTER</span> materials,
641 <span class="k">ADD COLUMN</span> tested_printers <span class="k">VARCHAR</span>(255) <span class="k">NULL</span> <span class="k">AFTER</span> file_options,
642 <span class="k">ADD COLUMN</span> hero_image_url <span class="k">VARCHAR</span>(255) <span class="k">NULL</span> <span class="k">AFTER</span> tested_printers,
643 <span class="k">ADD COLUMN</span> hero_image_pos <span class="k">VARCHAR</span>(20) <span class="k">NULL</span> <span class="k">AFTER</span> hero_image_url,
644 <span class="k">ADD COLUMN</span> purged_at <span class="k">TIMESTAMP NULL DEFAULT NULL</span> <span class="k">AFTER</span> hero_image_pos,
645 <span class="k">ADD KEY</span> idx_models_scheduled (scheduled_publish_at);</code></pre>
646
647 <p>The two <code>hero_image_*</code> columns store the per-model hero image (URL via <code>/img.cgi?id=...</code>) and its focal-point position (CSS <code>background-position</code>). They are edited in-place from the storefront when this model is the featured product, via <code>update_model_field.cgi</code>. NULL falls back to the layout's placeholder image at <code>/assets/images/&lt;idx&gt;.webp</code>.</p>
648
649 <p><code>purged_at</code> is the &ldquo;permanent delete by user&rdquo; flag. It is set by <code>/purge_models.cgi</code> when the creator empties their Trash. Every user-facing query joins <code>WHERE purged_at IS NULL</code> so the row becomes invisible to the user (and update endpoints refuse to write to it). Admin tooling can still see purged rows for moderation / recovery. Nothing is actually destroyed by the user.</p>
650
651 <p>The upload page also writes selected marketplaces to <code>marketplace_pushes</code> (status <code>queued</code>) and saves uploaded files under <code>httpdocs/uploads/models/&lt;storefront_id&gt;/&lt;model_id&gt;/</code> (same <code>.htaccess</code>-protected tree as page images). The <strong>plan_tier</strong> field on <code>users</code> drives demo mode: free-tier creators can pick at most two marketplaces per push; paid tiers are unlimited.</p>
652
653 <h3>5.8 — Admin Packages (plans + entitlements)</h3>
654 <p>Existing pricing pages now read from <code>billing_plans</code>, and the &ldquo;Available Features&rdquo; toggles on <code>/settings.cgi</code> are gated by a new entitlement table <code>plan_features</code>. The admin console at <code>/admin_packages.cgi</code> lets staff edit plan price / display name / marketing copy / included features without a code change. Fresh installs from the current <code>db_schema.sql</code> already include everything; <strong>existing databases</strong> need the migration below.</p>
655
656 <h4>DATABASE CHANGES TO RUN</h4>
657 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">USE</span> `WEBSTLs`;
658
659<span class="c">-- 1. Relax tier from ENUM to VARCHAR so admins can add custom tiers</span>
660<span class="c">-- from /admin_packages.cgi without a schema migration.</span>
661<span class="p">mysql&gt;</span> <span class="k">ALTER TABLE</span> billing_plans <span class="k">MODIFY</span> tier <span class="k">VARCHAR</span>(40) <span class="k">NOT NULL</span>;
662
663<span class="c">-- 2. New admin-managed columns on billing_plans.</span>
664<span class="p">mysql&gt;</span> <span class="k">ALTER TABLE</span> billing_plans
665 <span class="k">ADD COLUMN</span> tagline <span class="k">VARCHAR</span>(255) <span class="k">NULL</span> <span class="k">AFTER</span> display_name,
666 <span class="k">ADD COLUMN</span> cta_label <span class="k">VARCHAR</span>(60) <span class="k">NULL</span> <span class="k">AFTER</span> tagline,
667 <span class="k">ADD COLUMN</span> is_featured <span class="k">TINYINT</span>(1) <span class="k">NOT NULL DEFAULT</span> 0 <span class="k">AFTER</span> is_active;
668
669<span class="c">-- 3. Per-plan feature entitlements. Read by settings.cgi to decide</span>
670<span class="c">-- whether each toggle is Included or Locked (Upgrade CTA).</span>
671<span class="p">mysql&gt;</span> <span class="k">CREATE TABLE</span> plan_features (
672 plan_id BIGINT UNSIGNED <span class="k">NOT NULL</span>,
673 feature_key <span class="k">VARCHAR</span>(64) <span class="k">NOT NULL</span>,
674 is_included <span class="k">TINYINT</span>(1) <span class="k">NOT NULL DEFAULT</span> 0,
675 updated_at <span class="k">TIMESTAMP</span> <span class="k">NOT NULL DEFAULT CURRENT_TIMESTAMP</span>,
676 <span class="k">PRIMARY KEY</span> (plan_id, feature_key),
677 <span class="k">CONSTRAINT</span> fk_plan_features_plan <span class="k">FOREIGN KEY</span> (plan_id)
678 <span class="k">REFERENCES</span> billing_plans (id) <span class="k">ON DELETE CASCADE</span>
679 ) <span class="k">ENGINE</span>=InnoDB <span class="k">DEFAULT CHARSET</span>=utf8mb4 <span class="k">ROW_FORMAT</span>=DYNAMIC;
680
681<span class="c">-- 4. Seed marketing copy + the "Most popular" flag on the existing rows.</span>
682<span class="p">mysql&gt;</span> <span class="k">UPDATE</span> billing_plans <span class="k">SET</span> is_featured=1 <span class="k">WHERE</span> tier=<span class="s">'pro'</span>;
683<span class="p">mysql&gt;</span> <span class="k">UPDATE</span> billing_plans <span class="k">SET</span> tagline=<span class="s">'Solo creators just publishing to one or two marketplaces.'</span>,
684 cta_label=<span class="s">'Start free'</span> <span class="k">WHERE</span> tier=<span class="s">'free'</span>;
685<span class="p">mysql&gt;</span> <span class="k">UPDATE</span> billing_plans <span class="k">SET</span> tagline=<span class="s">'Active creators publishing to several marketplaces.'</span>,
686 cta_label=<span class="s">'Start 14-day free trial'</span> <span class="k">WHERE</span> tier=<span class="s">'starter'</span>;
687<span class="p">mysql&gt;</span> <span class="k">UPDATE</span> billing_plans <span class="k">SET</span> tagline=<span class="s">'Storefront + optimization for creators going independent.'</span>,
688 cta_label=<span class="s">'Start 14-day free trial'</span> <span class="k">WHERE</span> tier=<span class="s">'pro'</span>;
689<span class="p">mysql&gt;</span> <span class="k">UPDATE</span> billing_plans <span class="k">SET</span> tagline=<span class="s">'Teams. Multiple storefronts. Pooled-data recommendations.'</span>,
690 cta_label=<span class="s">'Talk to sales'</span> <span class="k">WHERE</span> tier=<span class="s">'studio'</span>;
691
692<span class="c">-- 5. Seed entitlements. Pro/Studio get every feature; Starter</span>
693<span class="c">-- unlocks publishing/optimization in addition to the Free set;</span>
694<span class="c">-- Free gets storefront/audience/analytics (and explicit locked</span>
695<span class="c">-- rows for publishing/optimization so the settings page knows</span>
696<span class="c">-- to render an Upgrade CTA).</span>
697<span class="p">mysql&gt;</span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included)
698 <span class="k">SELECT</span> id, <span class="s">'storefront'</span>, 1 <span class="k">FROM</span> billing_plans;
699<span class="p">mysql&gt;</span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included)
700 <span class="k">SELECT</span> id, <span class="s">'audience'</span>, 1 <span class="k">FROM</span> billing_plans;
701<span class="p">mysql&gt;</span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included)
702 <span class="k">SELECT</span> id, <span class="s">'analytics'</span>, 1 <span class="k">FROM</span> billing_plans;
703<span class="p">mysql&gt;</span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included)
704 <span class="k">SELECT</span> id, <span class="s">'publishing'</span>, 1 <span class="k">FROM</span> billing_plans <span class="k">WHERE</span> tier <span class="k">IN</span> (<span class="s">'starter'</span>,<span class="s">'pro'</span>,<span class="s">'studio'</span>);
705<span class="p">mysql&gt;</span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included)
706 <span class="k">SELECT</span> id, <span class="s">'optimization'</span>, 1 <span class="k">FROM</span> billing_plans <span class="k">WHERE</span> tier <span class="k">IN</span> (<span class="s">'starter'</span>,<span class="s">'pro'</span>,<span class="s">'studio'</span>);
707<span class="p">mysql&gt;</span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included)
708 <span class="k">SELECT</span> id, <span class="s">'publishing'</span>, 0 <span class="k">FROM</span> billing_plans <span class="k">WHERE</span> tier=<span class="s">'free'</span>;
709<span class="p">mysql&gt;</span> <span class="k">INSERT INTO</span> plan_features (plan_id, feature_key, is_included)
710 <span class="k">SELECT</span> id, <span class="s">'optimization'</span>, 0 <span class="k">FROM</span> billing_plans <span class="k">WHERE</span> tier=<span class="s">'free'</span>;</code></pre>
711
712 <p>Verify with:</p>
713 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">SELECT</span> tier, cadence, price_cents, is_featured <span class="k">FROM</span> billing_plans <span class="k">ORDER BY</span> sort_order;
714<span class="p">mysql&gt;</span> <span class="k">SELECT</span> bp.tier, pf.feature_key, pf.is_included
715 <span class="k">FROM</span> plan_features pf <span class="k">JOIN</span> billing_plans bp <span class="k">ON</span> bp.id=pf.plan_id
716 <span class="k">ORDER BY</span> bp.sort_order, pf.feature_key;</code></pre>
717
718 <p>The canonical feature catalogue (the keys you can attach to a plan) lives in <code>MODS/WebSTLs/Billing.pm</code> as <code>@FEATURE_CATALOG</code>. Edit that list to add a new feature key &mdash; the admin Packages UI picks it up automatically and the settings page renders it as a new row. The seeded keys today are <code>publishing</code>, <code>storefront</code>, <code>optimization</code>, <code>analytics</code>, <code>audience</code>.</p>
719
720 <h4>5.8a &mdash; Monthly + yearly on one row (post-consolidation)</h4>
721 <p>The original billing schema kept a separate <code>billing_plans</code> row for each (tier, cadence) pair, so adding the four standard plans landed eight rows. We've collapsed that to <strong>one row per tier</strong>: monthly price lives on <code>price_cents</code>, the yearly upgrade is expressed as one of three modes via the new <code>yearly_*</code> columns, and the per-subscription <code>subscriptions.cadence</code> column records whether each subscriber is billed monthly or annually.</p>
722 <p>Admins can now configure annual pricing per tier from <code>/admin_packages.cgi</code> &rarr; Edit, using whichever mode they prefer:</p>
723 <ul>
724 <li><code>yearly_mode='none'</code> &mdash; tier offers monthly billing only (Free tier).</li>
725 <li><code>yearly_mode='percent_off'</code> &mdash; <code>yearly_pct_off</code>% off the (monthly &times; 12) total. 17% &asymp; "2 months free"; 20% is a clean round discount.</li>
726 <li><code>yearly_mode='dollars_off'</code> &mdash; subtract <code>yearly_off_cents</code> from the 12-month total.</li>
727 <li><code>yearly_mode='explicit'</code> &mdash; <code>yearly_price_cents</code> is the exact annual price (used by the seeded plans: $90, $290, $990 = exactly 10x monthly).</li>
728 </ul>
729 <p>The effective annual price is resolved at runtime by <code>MODS::WebSTLs::Billing::compute_yearly_price_cents()</code>, which both the pricing page and the MRR/ARR queries call.</p>
730
731 <h4>Migrating an existing database</h4>
732 <p>If you ran the older 8-rows-per-tier schema, apply the following once. <strong>Safe to run with active subscriptions</strong> &mdash; we backfill <code>subscriptions.cadence</code> from the soon-to-be-deleted annual plan rows before deleting them.</p>
733 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">USE</span> `WEBSTLs`;
734<span class="c">-- 1. New yearly-* columns on billing_plans.</span>
735<span class="p">mysql&gt;</span> <span class="k">ALTER TABLE</span> billing_plans
736 <span class="k">ADD COLUMN</span> yearly_mode <span class="k">ENUM</span>(<span class="s">'none'</span>,<span class="s">'percent_off'</span>,<span class="s">'dollars_off'</span>,<span class="s">'explicit'</span>) <span class="k">NOT NULL</span> <span class="k">DEFAULT</span> <span class="s">'none'</span> <span class="k">AFTER</span> price_cents,
737 <span class="k">ADD COLUMN</span> yearly_pct_off <span class="k">TINYINT UNSIGNED</span> <span class="k">NOT NULL</span> <span class="k">DEFAULT</span> 0 <span class="k">AFTER</span> yearly_mode,
738 <span class="k">ADD COLUMN</span> yearly_off_cents <span class="k">INT UNSIGNED</span> <span class="k">NOT NULL</span> <span class="k">DEFAULT</span> 0 <span class="k">AFTER</span> yearly_pct_off,
739 <span class="k">ADD COLUMN</span> yearly_price_cents <span class="k">INT UNSIGNED</span> <span class="k">NOT NULL</span> <span class="k">DEFAULT</span> 0 <span class="k">AFTER</span> yearly_off_cents;
740<span class="c">-- 2. cadence on subscriptions (which used to be inferred from the plan).</span>
741<span class="p">mysql&gt;</span> <span class="k">ALTER TABLE</span> subscriptions
742 <span class="k">ADD COLUMN</span> cadence <span class="k">ENUM</span>(<span class="s">'monthly'</span>,<span class="s">'annual'</span>) <span class="k">NOT NULL</span> <span class="k">DEFAULT</span> <span class="s">'monthly'</span> <span class="k">AFTER</span> plan_id;
743<span class="c">-- 3. For any live annual subscription, pin cadence='annual' before</span>
744<span class="c">-- we drop the annual plan rows it points at.</span>
745<span class="p">mysql&gt;</span> <span class="k">UPDATE</span> subscriptions s
746 <span class="k">JOIN</span> billing_plans p <span class="k">ON</span> p.id = s.plan_id
747 <span class="k">SET</span> s.cadence=<span class="s">'annual'</span>
748 <span class="k">WHERE</span> p.cadence=<span class="s">'annual'</span>;
749<span class="c">-- 4. Copy each annual row's price onto the corresponding monthly row.</span>
750<span class="p">mysql&gt;</span> <span class="k">UPDATE</span> billing_plans m
751 <span class="k">JOIN</span> billing_plans a <span class="k">ON</span> a.tier = m.tier <span class="k">AND</span> a.cadence=<span class="s">'annual'</span>
752 <span class="k">SET</span> m.yearly_mode = <span class="s">'explicit'</span>, m.yearly_price_cents = a.price_cents
753 <span class="k">WHERE</span> m.cadence=<span class="s">'monthly'</span>;
754<span class="p">mysql&gt;</span> <span class="k">UPDATE</span> billing_plans <span class="k">SET</span> yearly_mode=<span class="s">'none'</span>, yearly_price_cents=0 <span class="k">WHERE</span> tier=<span class="s">'free'</span>;
755<span class="c">-- 5. Repoint any annual-subscription's plan_id at the surviving monthly row.</span>
756<span class="p">mysql&gt;</span> <span class="k">UPDATE</span> subscriptions s
757 <span class="k">JOIN</span> billing_plans a <span class="k">ON</span> a.id = s.plan_id <span class="k">AND</span> a.cadence=<span class="s">'annual'</span>
758 <span class="k">JOIN</span> billing_plans m <span class="k">ON</span> m.tier = a.tier <span class="k">AND</span> m.cadence=<span class="s">'monthly'</span>
759 <span class="k">SET</span> s.plan_id = m.id;
760<span class="c">-- 6. Drop the now-redundant annual rows + their plan_features.</span>
761<span class="p">mysql&gt;</span> <span class="k">DELETE</span> pf <span class="k">FROM</span> plan_features pf
762 <span class="k">JOIN</span> billing_plans bp <span class="k">ON</span> bp.id = pf.plan_id
763 <span class="k">WHERE</span> bp.cadence=<span class="s">'annual'</span>;
764<span class="p">mysql&gt;</span> <span class="k">DELETE</span> <span class="k">FROM</span> billing_plans <span class="k">WHERE</span> cadence=<span class="s">'annual'</span>;</code></pre>
765 <p>After this the catalogue has 4 rows (one per tier). The <code>billing_plans.cadence</code> column itself is retained (always <code>'monthly'</code>) for backward compatibility with old SELECTs, but is no longer the unique key.</p>
766
767 <h3>5.9 — Visitor region (country drill-down)</h3>
768 <p>The world map on <code>/admin_visitors.cgi</code> lets staff click a country and drill into a state/region breakdown. Region data lives on <code>tracking_sessions.region</code> (free-form, e.g. <code>"California"</code>, <code>"Bayern"</code>). Fresh installs from the current <code>db_schema.sql</code> already include it; <strong>existing databases</strong> need this ALTER:</p>
769
770 <h4>DATABASE CHANGES TO RUN</h4>
771 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">USE</span> `WEBSTLs`;
772<span class="p">mysql&gt;</span> <span class="k">ALTER TABLE</span> tracking_sessions
773 <span class="k">ADD COLUMN</span> region <span class="k">VARCHAR</span>(80) <span class="k">NULL</span> <span class="k">AFTER</span> country;</code></pre>
774
775 <p>The drill-down panel queries the column only when it exists (<code>region_column_ready()</code> in <code>MODS::WebSTLs::Tracking</code>), so the migration is non-blocking &mdash; the rest of the visitors page keeps working without it; the per-country drill just shows a friendly "no region data yet" notice in place of the state bars.</p>
776
777 <p>Populating the column is a separate step: either extend <code>track.js</code> to send a state name (best-effort from timezone or browser locale), or wire server-side IP&rarr;region lookup in the ingest endpoint. The column accepts any free-form label so a Phase-1 implementation can store whatever the tracker can derive.</p>
778
779 <h3>5.11 &mdash; Seller categories (storefront filter chips + upload picker)</h3>
780 <p>Each seller has their own list of categories (e.g. <em>"Halloween 2026"</em>, <em>"Patreon exclusive"</em>) used both as the Category select on <code>/upload_model.cgi</code> and as the storefront filter chips. New sellers receive a one-time merge of the rows in <code>default_categories</code> into their <code>seller_categories</code> at first touch of <code>/categories.cgi</code> or <code>/upload_model.cgi</code> &mdash; admins edit the default list from <code>/admin_categories.cgi</code>. The merge runs once per seller (gated by <code>users.categories_seeded_at</code>), so subsequent admin edits affect future sellers only.</p>
781
782 <p>Fresh installs from the current <code>db_schema.sql</code> already include all three tables, the sentinel column on <code>users</code>, and the seed rows. <strong>Existing databases</strong> need this migration:</p>
783
784 <h4>DATABASE CHANGES TO RUN</h4>
785 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">USE</span> `WEBSTLs`;
786<span class="p">mysql&gt;</span> <span class="k">CREATE TABLE</span> default_categories (
787 id <span class="k">BIGINT UNSIGNED NOT NULL AUTO_INCREMENT</span>,
788 slug <span class="k">VARCHAR</span>(80) <span class="k">NOT NULL</span>,
789 name <span class="k">VARCHAR</span>(120) <span class="k">NOT NULL</span>,
790 description <span class="k">VARCHAR</span>(255) <span class="k">NULL</span>,
791 icon <span class="k">VARCHAR</span>(40) <span class="k">NULL</span>,
792 color <span class="k">VARCHAR</span>(20) <span class="k">NULL</span>,
793 sort_order <span class="k">SMALLINT NOT NULL DEFAULT</span> 0,
794 is_public <span class="k">TINYINT</span>(1) <span class="k">NOT NULL DEFAULT</span> 1,
795 created_at <span class="k">TIMESTAMP NULL DEFAULT NULL</span>,
796 updated_at <span class="k">TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP</span>,
797 <span class="k">PRIMARY KEY</span> (id),
798 <span class="k">UNIQUE KEY</span> uq_dc_slug (slug)
799 ) <span class="k">ENGINE</span>=InnoDB <span class="k">DEFAULT CHARSET</span>=utf8mb4 <span class="k">ROW_FORMAT</span>=DYNAMIC;
800
801<span class="p">mysql&gt;</span> <span class="k">ALTER TABLE</span> users
802 <span class="k">ADD COLUMN</span> categories_seeded_at <span class="k">TIMESTAMP NULL DEFAULT NULL AFTER</span> created_at;
803
804<span class="c">-- Seed the default list (25 rows). See db_schema.sql for the full INSERT.</span></code></pre>
805
806 <p>If your install pre-dates the <code>seller_categories</code> / <code>model_seller_categories</code> tables (the per-seller list itself, not just the defaults), run those CREATE TABLE statements from <code>db_schema.sql</code> first &mdash; <code>default_categories</code> doesn't depend on them, but the seed merge needs them.</p>
807
808 <p>The merge is gated on <code>users.categories_seeded_at IS NULL</code>, so it's safe to run the migration on a live system &mdash; existing sellers who have already curated their list (timestamp already set on their first visit) won't be touched, and sellers who haven't visited yet will get the merge on their next pageload.</p>
809
810 <h3>5.12 &mdash; Fulfillment + shipping (physical orders)</h3>
811 <p>Sellers who list <code>product_kind</code> in <code>'physical'</code> or <code>'both'</code> need a place to walk orders through their lifecycle (new &rarr; printing &rarr; packed &rarr; shipped &rarr; delivered), record tracking numbers, and print labels. Six new tables plus a handful of columns on <code>orders</code> and <code>order_items</code> back this workflow. Fresh installs from the current <code>db_schema.sql</code> include them all; <strong>existing databases</strong> need this migration:</p>
812
813 <h4>DATABASE CHANGES TO RUN</h4>
814 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">USE</span> `WEBSTLs`;
815
816<span class="c">-- Per-seller settings: origin address, defaults.</span>
817<span class="p">mysql&gt;</span> <span class="k">CREATE TABLE</span> seller_shipping_settings (
818 user_id <span class="k">BIGINT UNSIGNED NOT NULL</span>,
819 origin_name <span class="k">VARCHAR</span>(120) <span class="k">NULL</span>,
820 origin_company <span class="k">VARCHAR</span>(120) <span class="k">NULL</span>,
821 origin_line1 <span class="k">VARCHAR</span>(160) <span class="k">NULL</span>,
822 origin_line2 <span class="k">VARCHAR</span>(160) <span class="k">NULL</span>,
823 origin_city <span class="k">VARCHAR</span>(120) <span class="k">NULL</span>,
824 origin_region <span class="k">VARCHAR</span>(80) <span class="k">NULL</span>,
825 origin_postal <span class="k">VARCHAR</span>(20) <span class="k">NULL</span>,
826 origin_country <span class="k">CHAR</span>(2) <span class="k">NULL</span>,
827 origin_phone <span class="k">VARCHAR</span>(40) <span class="k">NULL</span>,
828 origin_email <span class="k">VARCHAR</span>(255) <span class="k">NULL</span>,
829 default_carrier <span class="k">ENUM</span>('usps','ups','fedex','dhl','other','manual') <span class="k">NOT NULL DEFAULT</span> 'manual',
830 default_service <span class="k">VARCHAR</span>(80) <span class="k">NULL</span>,
831 auto_email_tracking <span class="k">TINYINT</span>(1) <span class="k">NOT NULL DEFAULT</span> 1,
832 require_signature <span class="k">TINYINT</span>(1) <span class="k">NOT NULL DEFAULT</span> 0,
833 handling_fee_cents <span class="k">INT UNSIGNED NOT NULL DEFAULT</span> 0,
834 default_handling_days <span class="k">TINYINT UNSIGNED NOT NULL DEFAULT</span> 2,
835 insurance_default_cents <span class="k">INT UNSIGNED NULL</span>,
836 updated_at <span class="k">TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP</span>,
837 <span class="k">PRIMARY KEY</span> (user_id)
838 );
839
840<span class="c">-- Carrier credentials per seller (one row per carrier they ship with).</span>
841<span class="p">mysql&gt;</span> <span class="k">CREATE TABLE</span> shipping_carriers (...); <span class="c">-- see db_schema.sql for full definition</span>
842<span class="p">mysql&gt;</span> <span class="k">CREATE TABLE</span> shipping_zones (...); <span class="c">-- rate-rule table the estimator walks</span>
843<span class="p">mysql&gt;</span> <span class="k">CREATE TABLE</span> shipping_labels (...); <span class="c">-- one record per shipment</span>
844<span class="p">mysql&gt;</span> <span class="k">CREATE TABLE</span> shipment_events (...); <span class="c">-- carrier status updates per label</span>
845<span class="p">mysql&gt;</span> <span class="k">CREATE TABLE</span> fulfillment_notes (...); <span class="c">-- per-order internal notes</span>
846
847<span class="c">-- orders: lifecycle status + shipping method + notes</span>
848<span class="p">mysql&gt;</span> <span class="k">ALTER TABLE</span> orders
849 <span class="k">ADD COLUMN</span> fulfillment_status <span class="k">ENUM</span>('new','processing','printing','printed','packed','shipped','partially_shipped','delivered','completed','on_hold','cancelled','returned') <span class="k">NOT NULL DEFAULT</span> 'new',
850 <span class="k">ADD COLUMN</span> fulfilled_at <span class="k">TIMESTAMP NULL DEFAULT NULL</span>,
851 <span class="k">ADD COLUMN</span> seller_notes <span class="k">TEXT NULL</span>,
852 <span class="k">ADD COLUMN</span> buyer_notes <span class="k">TEXT NULL</span>,
853 <span class="k">ADD COLUMN</span> shipping_method <span class="k">VARCHAR</span>(80) <span class="k">NULL</span>,
854 <span class="k">ADD KEY</span> idx_orders_fulfillment (fulfillment_status, created_at);
855
856<span class="c">-- order_items: link to label record + expand the lifecycle enum</span>
857<span class="p">mysql&gt;</span> <span class="k">ALTER TABLE</span> order_items
858 <span class="k">ADD COLUMN</span> label_id <span class="k">BIGINT UNSIGNED NULL</span>,
859 <span class="k">ADD COLUMN</span> notes <span class="k">TEXT NULL</span>,
860 <span class="k">MODIFY COLUMN</span> fulfillment_status
861 <span class="k">ENUM</span>('not_required','new','pending','processing','printing','printed','packed',
862 'shipped','delivered','completed','on_hold','cancelled','canceled','returned')
863 <span class="k">NOT NULL DEFAULT</span> 'not_required';</code></pre>
864
865 <p>The full <code>CREATE TABLE</code> bodies (with all foreign-key constraints + indexes) are in <code>db_schema.sql</code> under section &sect;14. UI surfaces consuming this:</p>
866 <ul>
867 <li><code>/fulfill.cgi</code> &mdash; status-tabbed list of physical orders</li>
868 <li><code>/fulfill_order.cgi?order_id=N</code> &mdash; per-order detail (addresses, items, label/tracking, notes, status changes)</li>
869 <li><code>/shipping_settings.cgi</code> &mdash; origin address, carriers, zones (rate rules)</li>
870 <li><code>/print_label.cgi?label_id=N</code> &mdash; printable label + packing slip</li>
871 </ul>
872 <p>The rate estimator on the order detail page walks <code>shipping_zones</code> in <code>sort_order</code> and picks the first whose <code>country_codes</code> list matches the buyer's destination. Live carrier API integration (USPS / UPS / FedEx) is stubbed: the credentials forms in <code>/shipping_settings.cgi</code> save to <code>shipping_carriers</code> but the dispatcher uses the rule-based estimator until an API class is wired. Labels are recorded manually (you paste the tracking number from the carrier's site); <code>/print_label.cgi</code> renders a print-ready HTML page (not carrier-accepted postage &mdash; that needs the live API).</p>
873
874 <h3>5.13 &mdash; Listing upload rename (<code>/upload.cgi</code> &rarr; <code>/upload_model.cgi</code>)</h3>
875 <p>The upload form was renamed so the URL maps to the DB tables it writes (<code>models</code>, <code>model_files</code>, <code>model_images</code>, <code>model_tags</code>). The old <code>/upload.cgi</code> remains as a tiny 301 stub so any external bookmarks and marketplace-export links keep working; everything internal now points at <code>/upload_model.cgi</code>. The sidebar "New Listing" entry was removed &mdash; the upload action lives on <code>/models.cgi</code> as an "Upload new model" button. No DB changes for this rename.</p>
876
877 <h3>5.10 — Compatibility note (MySQL / MariaDB versions)</h3>
878 <p>The schema is hand-tuned for portability. It runs on <strong>MariaDB 5.5+ and MySQL 5.5+</strong> without changes thanks to several deliberate trade-offs:</p>
879 <ul>
880 <li><code>TIMESTAMP</code> instead of <code>DATETIME</code> — old MySQL only allowed <code>CURRENT_TIMESTAMP</code> defaults on <code>TIMESTAMP</code>. Note: <code>TIMESTAMP</code> values can't go past 2038-01-19 — a known issue we'll address by migrating to <code>DATETIME</code> when on MySQL 5.6+ exclusively.</li>
881 <li><code>BEFORE INSERT</code> / <code>BEFORE UPDATE</code> triggers fill <code>created_at</code> and <code>updated_at</code> — pre-MySQL-5.6.5 only allows one <code>CURRENT_TIMESTAMP</code> column per table.</li>
882 <li><code>LONGTEXT</code> instead of <code>JSON</code> — JSON column type didn't arrive until MariaDB 10.2 / MySQL 5.7. App code parses JSON on read.</li>
883 <li>No <code>FULLTEXT</code> indexes — MariaDB 5.5 only supports FULLTEXT on MyISAM, and FULLTEXT on InnoDB scales poorly anyway. Catalog search uses <code>LIKE</code>; for serious search at scale plug in Meilisearch or Typesense.</li>
884 <li><code>VARCHAR(191)</code> on indexed string columns — old InnoDB caps index keys at 767 bytes, and 191×4 (utf8mb4) = 764. <code>email</code>, <code>slug</code>, and <code>custom_domain</code> all sit at 191.</li>
885 <li>No <code>INET6_ATON()</code> in SQL — added in MariaDB 10.0+. The app packs IPs in Perl using <code>Socket::inet_pton</code> instead.</li>
886 <li>No <code>utf8mb4_0900_ai_ci</code> collation — that's MySQL 8.0+ only. Server default <code>utf8mb4</code> collation is fine.</li>
887 </ul>
888 <p>Upgrading to MariaDB 10.5+ or MySQL 8.0+ is recommended when feasible — you get JSON validation, fractional-second timestamps, native FULLTEXT on InnoDB, and the larger 3072-byte index limit, but none of it is blocking.</p>
889 </section>
890
891 <!-- 6. CONFIG -->
892 <section id="config">
893 <h2><span class="num">06</span> Configure the app</h2>
894 <p>Two files need editing before the site can boot.</p>
895
896 <h3>6.1 — Database credentials</h3>
897 <p>Open <code>MODS/DBConnect.pm</code> and fill in the top block (lines 5-9):</p>
898
899 <pre data-label="MODS/DBConnect.pm"><code><span class="c"># Default DB — used by every .cgi page on webstls.com</span>
900<span class="k">my</span> <span class="v">$default_db_host</span> = <span class="s">'localhost'</span>; <span class="c"># or RDS endpoint</span>
901<span class="k">my</span> <span class="v">$default_db_name</span> = <span class="s">'WEBSTLS'</span>;
902<span class="k">my</span> <span class="v">$default_db_login</span> = <span class="s">'webstls'</span>;
903<span class="k">my</span> <span class="v">$default_db_password</span> = <span class="s">'CHANGE_ME_STRONG_PASSWORD'</span>;
904<span class="k">my</span> <span class="v">$default_db_port</span> = <span class="s">'3306'</span>;</code></pre>
905
906 <h3>6.2 — WebSTLs settings</h3>
907 <p>Open <code>MODS/WebSTLs/Config.pm</code>. The defaults are fine for most installs — confirm these:</p>
908
909 <pre data-label="MODS/WebSTLs/Config.pm"><code><span class="v">database_name</span> =&gt; <span class="s">'WEBSTLS'</span>, <span class="c"># match your CREATE DATABASE</span>
910<span class="v">website_title</span> =&gt; <span class="s">'WebSTLs — 3D Printable Designs Platform'</span>,
911<span class="v">cookie_name</span> =&gt; <span class="s">'webstls_session'</span>,
912<span class="v">session_minutes</span> =&gt; <span class="n">720</span>, <span class="c"># 12h</span>
913<span class="v">secure_cookies</span> =&gt; <span class="n">1</span>, <span class="c"># 1 = HTTPS only</span>
914<span class="v">assets</span> =&gt; <span class="s">'/assets'</span>,
915<span class="v">from_email</span> =&gt; <span class="s">'noreply@webstls.com'</span>,</code></pre>
916
917 <p>If you're on plain HTTP during initial testing, temporarily set <code>secure_cookies =&gt; 0</code> so cookies are sent. Switch back to <code>1</code> once HTTPS is live.</p>
918
919 <h3>6.3 — (optional) Node-name tag</h3>
920 <p><code>MODS/Config.pm</code> reads <code>MODS/nodename.txt</code> to display a "STAGING" corner banner. The read is now optional — if the file is missing, the banner is silently disabled. To enable on staging:</p>
921
922 <pre data-label="bash"><code><span class="p">$</span> <span class="k">echo</span> <span class="s">"webstls-stage-01"</span> &gt; /var/www/vhosts/webstls.com/httpdocs/MODS/nodename.txt</code></pre>
923 </section>
924
925 <!-- 7. APACHE -->
926 <section id="apache">
927 <h2><span class="num">07</span> Apache / vhost</h2>
928 <p>WebSTLs is plain CGI — every <code>.cgi</code> file should be executed, every other request served as a static file. <strong>The recommended approach is .htaccess</strong> — it works on Plesk, cPanel, DirectAdmin and bare Apache without touching the main server config.</p>
929
930 <h3>7.1 — Three .htaccess files (already in the source bundle)</h3>
931 <p>Three <code>.htaccess</code> files ship with the project. Upload all three; together they handle CGI execution and lock down internal directories. <strong>Do NOT use <code>&lt;Directory&gt;</code> blocks in .htaccess</strong> — Apache rejects the entire file with the cryptic error <code>"&lt;Directory not allowed here"</code> and returns 500 on every request.</p>
932
933 <pre data-label=".htaccess (webroot)"><code><span class="c"># /var/www/vhosts/webstls.com/httpdocs/.htaccess</span>
934
935<span class="k">Options</span> +ExecCGI -Indexes +FollowSymLinks
936<span class="k">AddHandler</span> cgi-script .cgi
937<span class="k">DirectoryIndex</span> index.cgi index.html
938
939<span class="c"># Block direct download of source / data / log files</span>
940&lt;<span class="k">FilesMatch</span> "\.(sql|pm|log|bak|prev|dat|db)$"&gt;
941 <span class="k">Require</span> all denied
942&lt;/<span class="k">FilesMatch</span>&gt;
943
944<span class="c"># Long-cache static assets</span>
945&lt;<span class="k">FilesMatch</span> "\.(css|js|woff2?|ttf|webp|png|jpg|svg|ico)$"&gt;
946 &lt;<span class="k">IfModule</span> mod_headers.c&gt;
947 <span class="k">Header</span> set Cache-Control "public, max-age=31536000, immutable"
948 &lt;/<span class="k">IfModule</span>&gt;
949&lt;/<span class="k">FilesMatch</span>&gt;
950
951<span class="c"># Light security headers</span>
952&lt;<span class="k">IfModule</span> mod_headers.c&gt;
953 <span class="k">Header</span> always set X-Content-Type-Options "nosniff"
954 <span class="k">Header</span> always set X-Frame-Options "SAMEORIGIN"
955 <span class="k">Header</span> always set Referrer-Policy "strict-origin-when-cross-origin"
956&lt;/<span class="k">IfModule</span>&gt;</code></pre>
957
958 <pre data-label=".htaccess (MODS/ and TEMPLATES/)"><code><span class="c"># MODS/.htaccess — and identical TEMPLATES/.htaccess</span>
959<span class="k">Require</span> all denied</code></pre>
960
961 <p>WinSCP / FileZilla hide dotfiles by default. In WinSCP, toggle <strong>Options → Preferences → Panels → Show hidden files</strong> (or press Ctrl+Alt+H) so the <code>.htaccess</code> files actually transfer.</p>
962
963 <h3>7.2 — Verify .htaccess uploaded</h3>
964 <pre data-label="bash"><code><span class="p">$</span> <span class="k">ls</span> -la /var/www/vhosts/webstls.com/httpdocs/.htaccess
965<span class="p">$</span> <span class="k">ls</span> -la /var/www/vhosts/webstls.com/httpdocs/MODS/.htaccess
966<span class="p">$</span> <span class="k">ls</span> -la /var/www/vhosts/webstls.com/httpdocs/TEMPLATES/.htaccess
967
968<span class="c"># Sanity-check: no &lt;Directory&gt; blocks in any of them</span>
969<span class="p">$</span> <span class="k">grep</span> -r "&lt;Directory" /var/www/vhosts/webstls.com/httpdocs/.htaccess /var/www/vhosts/webstls.com/httpdocs/MODS/.htaccess /var/www/vhosts/webstls.com/httpdocs/TEMPLATES/.htaccess
970<span class="c"># (no output = good)</span></code></pre>
971
972 <h3>7.3 — Plesk specifics</h3>
973 <p>On Plesk hosts, <strong>don't edit the vhost config files</strong> — Plesk regenerates them and clobbers your changes. Instead:</p>
974 <ul>
975 <li>Upload the three <code>.htaccess</code> files via SFTP/WinSCP (works as-is)</li>
976 <li>For things you can't put in .htaccess (custom error pages, mod_rewrite tweaks at vhost scope), use <strong>Websites &amp; Domains → webstls.com → Apache &amp; nginx Settings → Additional Apache directives for HTTPS</strong></li>
977 <li>Plesk creates the vhost log at <code>/var/www/vhosts/system/webstls.com/logs/error_log</code> — that's where CGI errors land, not <code>/var/log/httpd/error_log</code></li>
978 </ul>
979
980 <h3>7.4 — Bare-Apache vhost (non-Plesk hosts)</h3>
981 <p>If you control the server config directly, you can skip the .htaccess files and put everything in the vhost config — that's more efficient because Apache doesn't re-read .htaccess on every request:</p>
982
983 <pre data-label="apache vhost (bare Apache only)"><code>&lt;<span class="k">VirtualHost</span> *:443&gt;
984 <span class="k">ServerName</span> webstls.com
985 <span class="k">DocumentRoot</span> /var/www/vhosts/webstls.com/httpdocs
986
987 <span class="k">SSLEngine</span> on
988 <span class="k">SSLCertificateFile</span> /etc/letsencrypt/live/webstls.com/fullchain.pem
989 <span class="k">SSLCertificateKeyFile</span> /etc/letsencrypt/live/webstls.com/privkey.pem
990
991 &lt;<span class="k">Directory</span> /var/www/vhosts/webstls.com/httpdocs&gt;
992 <span class="k">Options</span> +ExecCGI -Indexes +FollowSymLinks
993 <span class="k">AddHandler</span> cgi-script .cgi
994 <span class="k">DirectoryIndex</span> index.cgi index.html
995 <span class="k">AllowOverride</span> None
996 <span class="k">Require</span> all granted
997 &lt;/<span class="k">Directory</span>&gt;
998 &lt;<span class="k">Directory</span> /var/www/vhosts/webstls.com/httpdocs/MODS&gt;
999 <span class="k">Require</span> all denied
1000 &lt;/<span class="k">Directory</span>&gt;
1001 &lt;<span class="k">Directory</span> /var/www/vhosts/webstls.com/httpdocs/TEMPLATES&gt;
1002 <span class="k">Require</span> all denied
1003 &lt;/<span class="k">Directory</span>&gt;
1004 &lt;<span class="k">FilesMatch</span> "\.(sql|pm|log)$"&gt;
1005 <span class="k">Require</span> all denied
1006 &lt;/<span class="k">FilesMatch</span>&gt;
1007
1008 <span class="k">ErrorLog</span> /var/log/httpd/webstls.com-error.log
1009 <span class="k">CustomLog</span> /var/log/httpd/webstls.com-access.log combined
1010&lt;/<span class="k">VirtualHost</span>&gt;</code></pre>
1011
1012 <p>Required Apache modules: <code>mod_cgi</code> or <code>mod_cgid</code>, <code>mod_headers</code>, <code>mod_ssl</code>, <code>mod_rewrite</code>. On RHEL/CentOS these are usually loaded by default; on Debian/Ubuntu run <code>a2enmod cgi headers ssl rewrite</code> and reload Apache.</p>
1013
1014 <h3>7.5 — Wildcard subdomains for storefronts (optional)</h3>
1015 <p>WebSTLs lets every creator pick a subdomain (e.g. <code>grimforge</code>) at signup. Without any extra setup, those storefronts are reachable at <code>webstls.com/store.cgi?subdomain=grimforge</code> — that always works. If you want the prettier <code>grimforge.webstls.com</code> URL to work too, do the steps below <strong>once</strong> on your platform, and every existing and future creator's subdomain becomes live automatically.</p>
1016
1017 <p style="color:var(--col-text-3);font-size:13px;margin-top:-8px"><em>This is optional. Skip the section entirely if you're happy with the path-based URLs — nothing in the codebase depends on subdomains being configured.</em></p>
1018
1019 <div class="banner info" style="margin-top:14px">
1020 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/></svg>
1021 <div class="text-xs"><strong>Critical Plesk gotcha:</strong> Plesk runs <strong>nginx as a reverse proxy in front of Apache</strong>. nginx routes requests by <code>server_name</code> <em>before</em> Apache ever sees them. A simple <code>ServerAlias *.webstls.com</code> in Apache directives <strong>does nothing on Plesk</strong> — nginx still doesn't recognize the hostname and serves a 72-byte fallback page. The signs you're hitting this: response has <code>Server: nginx</code>, tiny <code>Content-Length</code>, and a <code>Last-Modified</code> header from years ago (static fallback file). The fix is Step 2 below — add a wildcard subdomain through the Plesk UI, which regenerates <strong>both</strong> nginx and Apache configs to accept the wildcard.</div>
1022 </div>
1023
1024 <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Step 1 — Wildcard DNS A-record</h4>
1025 <p>Add a wildcard A-record at whichever DNS provider hosts <code>webstls.com</code> (Plesk DNS, Cloudflare, Route 53, GoDaddy — wherever your nameservers point). Most provider UIs:</p>
1026 <ul>
1027 <li><strong>Type:</strong> A</li>
1028 <li><strong>Host / Name:</strong> <code>*</code> (some UIs want <code>*.webstls.com</code>; both mean the same thing)</li>
1029 <li><strong>Value / Points to:</strong> your server's public IPv4 address</li>
1030 <li><strong>TTL:</strong> 3600 (1 hour) is fine</li>
1031 </ul>
1032 <p>If you also serve IPv6, add an AAAA record with the same wildcard host pointing at your server's IPv6 address.</p>
1033
1034 <p>Verify after DNS propagates (give it a minute):</p>
1035 <pre data-label="bash"><code><span class="p">$</span> <span class="k">dig</span> +short anything.webstls.com
1036<span class="c"># should print your server IP. Try a different "anything" — same answer.</span></code></pre>
1037
1038 <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Step 2 — Plesk: add a wildcard subdomain (recommended path)</h4>
1039 <p>This is the cleanest way on Plesk. It regenerates both nginx and Apache to handle wildcards properly — bypasses the "nginx ignores ServerAlias" trap entirely.</p>
1040
1041 <ol>
1042 <li>Plesk panel → <strong>Websites &amp; Domains</strong></li>
1043 <li>Click <strong>Add Subdomain</strong> (button near the top of the panel)</li>
1044 <li><strong>Subdomain name:</strong> <code>*</code> (a literal asterisk)</li>
1045 <li><strong>Document root:</strong> set to <code>httpdocs</code> — the <em>same</em> folder as the parent <code>webstls.com</code> uses. <strong>Don't</strong> let Plesk create a separate folder; we want the wildcard vhost serving the same files as the main vhost.</li>
1046 <li>Click <strong>OK</strong>. Plesk will run through "Adding a domain → Configuring the DNS zone → Creating physical hosting → Configuring PHP" — takes ~10 seconds.</li>
1047 </ol>
1048
1049 <p>After Plesk finishes:</p>
1050 <ul>
1051 <li>A new vhost <code>*.webstls.com</code> appears in your domains list with status <code>Active</code></li>
1052 <li>nginx and Apache both have the wildcard registered</li>
1053 <li>The vhost shares <code>/var/www/vhosts/webstls.com/httpdocs/</code> with the main site</li>
1054 </ul>
1055
1056 <p><strong>If Plesk creates a separate folder anyway</strong> (e.g. <code>/var/www/vhosts/webstls.com/wildcard.webstls.com/httpdocs/</code>): in the new <code>*.webstls.com</code> domain row, click <strong>Hosting &amp; DNS</strong> → <strong>Hosting Settings</strong>, change <strong>Document root</strong> to <code>httpdocs</code> (the parent's), save. Otherwise the wildcard vhost will serve from an empty directory.</p>
1057
1058 <p><strong>If Plesk shows a different IP</strong> for the wildcard subdomain than your DNS A-record points at: same screen, switch the IP to match. Usually it's a non-issue (Plesk's display IP is just a panel hint) but the curl test in Step 4 will reveal it for sure.</p>
1059
1060 <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Step 2b — Old way (don't use unless Step 2 isn't possible)</h4>
1061 <p>If your Plesk version blocks <code>*</code> as a subdomain name, or you're not on Plesk at all, you can manually paste these into the appropriate config field (Plesk's <strong>Apache &amp; nginx Settings → Additional directives for HTTPS</strong>, or the bare-Apache vhost from §7.4):</p>
1062
1063 <pre data-label="apache directives (fallback)"><code><span class="k">ServerAlias</span> *.webstls.com</code></pre>
1064
1065 <p>For nginx (Plesk's <strong>Additional nginx directives</strong> field):</p>
1066 <pre data-label="nginx directives (fallback)"><code><span class="k">server_name</span> webstls.com www.webstls.com *.webstls.com;</code></pre>
1067
1068 <p>The actual subdomain → <code>store.cgi?subdomain=…</code> rewrite lives in <code>.htaccess</code> (see Step 3) regardless of which path you choose.</p>
1069
1070 <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Step 3 — Rewrite logic in .htaccess (single source of truth)</h4>
1071 <p>The shipped <code>.htaccess</code> already includes the wildcard → <code>store.cgi</code> rewrite. Putting it in <code>.htaccess</code> instead of Plesk's per-vhost directives field means: (a) one source of truth, (b) Plesk regenerating its config can't wipe it, (c) it applies to <em>both</em> the main webstls.com vhost and the new <code>*.webstls.com</code> vhost since they share <code>httpdocs/</code>.</p>
1072
1073 <pre data-label=".htaccess (already in your webroot)"><code>&lt;<span class="k">IfModule</span> mod_rewrite.c&gt;
1074 <span class="k">RewriteEngine</span> On
1075
1076 <span class="c"># Don't rewrite the canonical hosts</span>
1077 <span class="k">RewriteCond</span> %{HTTP_HOST} !^webstls\.com$ [NC]
1078 <span class="k">RewriteCond</span> %{HTTP_HOST} !^www\.webstls\.com$ [NC]
1079
1080 <span class="c"># grimforge.webstls.com → /store.cgi?subdomain=grimforge</span>
1081 <span class="k">RewriteCond</span> %{HTTP_HOST} ^([a-z0-9-]+)\.webstls\.com$ [NC]
1082 <span class="k">RewriteRule</span> ^/?$ /store.cgi?subdomain=%1 [L,QSA]
1083&lt;/<span class="k">IfModule</span>&gt;</code></pre>
1084
1085 <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Step 3 — Wildcard SSL certificate</h4>
1086 <p>Plesk's default Let's Encrypt cert covers only <code>webstls.com</code> and <code>www.webstls.com</code>, so <code>grimforge.webstls.com</code> would show a browser cert warning. Get a wildcard cert that covers <code>*.webstls.com</code>:</p>
1087
1088 <ol>
1089 <li>Plesk panel → <strong>Websites &amp; Domains</strong> → <code>webstls.com</code> → <strong>SSL/TLS Certificates</strong> (or click <strong>SSL It!</strong> if that extension is installed)</li>
1090 <li>Click <strong>Get it free</strong> (Let's Encrypt) or <strong>Install</strong> if SSL It! is already configured</li>
1091 <li>Tick <strong>"Issue a wildcard SSL certificate"</strong> (or "Include a wildcard domain")</li>
1092 <li>Tick <strong>"Secure www subdomain"</strong> as well</li>
1093 <li>Click <strong>Get it free</strong> / <strong>Install</strong></li>
1094 </ol>
1095
1096 <div class="banner warning" style="margin-top:14px">
1097 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" y1="9" x2="12" y2="13"/></svg>
1098 <div class="text-xs"><strong>Wildcard certificates require DNS-01 validation</strong> — Let's Encrypt won't issue them over the simpler HTTP-01. Plesk handles this automatically <strong>if Plesk manages the DNS for webstls.com</strong>. If your DNS is somewhere else (Cloudflare, Route 53, etc.) you'll need to either: (a) move DNS to Plesk-managed nameservers; (b) use Plesk's "External DNS" extension if available for your provider; or (c) issue the cert by hand with <code>certbot --manual --preferred-challenges dns</code> and upload it to Plesk.</div>
1099 </div>
1100
1101 <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Step 4 — Verify</h4>
1102 <pre data-label="bash"><code><span class="p">$</span> <span class="k">curl</span> -I https://demo.webstls.com/
1103<span class="c"># Should return 200 OK, no cert warning. The body will be the demo storefront</span>
1104<span class="c"># (since "demo" doesn't match any real subdomain in the storefronts table).</span>
1105
1106<span class="p">$</span> <span class="k">curl</span> -I https://&lt;your-real-subdomain&gt;.webstls.com/
1107<span class="c"># Should return 200 OK with your real storefront's hero.</span></code></pre>
1108
1109 <p>Once those return clean 200s, every existing and future creator on the platform automatically has a working <code>&lt;subdomain&gt;.webstls.com</code> URL — no per-creator config, no DNS work, no SSL provisioning. The wildcard handles them all.</p>
1110
1111 <h4 style="font-family:var(--font-display);font-size:15px;color:var(--col-text);margin-top:24px;margin-bottom:8px">Custom domains (per creator)</h4>
1112 <p>Wildcard subdomains are the platform-wide default. If a creator wants their own brand domain (<code>grimforge.studio</code>), they:</p>
1113 <ol>
1114 <li>Add an A-record at their domain registrar pointing <code>grimforge.studio</code> at your server IP</li>
1115 <li>Enter <code>grimforge.studio</code> in the <strong>Domain &amp; SSL</strong> tab on their storefront editor</li>
1116 <li><code>store.cgi</code> resolves the storefront via <code>WHERE custom_domain = $HTTP_HOST</code> automatically</li>
1117 </ol>
1118 <p>SSL for custom domains is handled per-creator: the platform owner adds each domain as a Plesk "Domain Alias" or separate vhost and provisions a Let's Encrypt cert for it. (A future iteration of this app will automate that step via the Plesk API; for now it's a manual paste each time a creator adds a custom domain.)</p>
1119 </section>
1120
1121 <!-- 8. PERMISSIONS -->
1122 <section id="permissions">
1123 <h2><span class="num">08</span> File permissions</h2>
1124 <p>Apache must be able to read everything and execute the <code>.cgi</code> files.</p>
1125
1126 <pre data-label="bash"><code><span class="p">$</span> <span class="k">cd</span> /var/www/vhosts/webstls.com/httpdocs
1127
1128<span class="c"># Owner: your deploy user, group: web server</span>
1129<span class="p">$</span> <span class="k">chown</span> -R deploy:apache . <span class="c"># or :www-data on Debian</span>
1130
1131<span class="c"># Default: 750 dirs, 640 files</span>
1132<span class="p">$</span> <span class="k">find</span> . -type d -exec chmod 750 {} \;
1133<span class="p">$</span> <span class="k">find</span> . -type f -exec chmod 640 {} \;
1134
1135<span class="c"># CGI scripts must be executable</span>
1136<span class="p">$</span> <span class="k">chmod</span> 750 *.cgi
1137
1138<span class="c"># Schema file should not be web-readable</span>
1139<span class="p">$</span> <span class="k">chmod</span> 600 db_schema.sql
1140<span class="p">$</span> <span class="k">chmod</span> 600 MODS/DBConnect.pm <span class="c"># contains DB password</span>
1141
1142<span class="c"># DB error log must be writable by Apache</span>
1143<span class="p">$</span> <span class="k">touch</span> MODS/DB_ERRORS.log
1144<span class="p">$</span> <span class="k">chown</span> apache:apache MODS/DB_ERRORS.log
1145<span class="p">$</span> <span class="k">chmod</span> 660 MODS/DB_ERRORS.log</code></pre>
1146
1147 <p>If SELinux is enforcing, the .cgi context must be <code>httpd_sys_script_exec_t</code>:</p>
1148 <pre data-label="bash"><code><span class="p">$</span> <span class="k">restorecon</span> -Rv /var/www/vhosts/webstls.com/httpdocs
1149<span class="p">$</span> <span class="k">setsebool</span> -P httpd_can_network_connect_db on <span class="c"># if DB is remote</span></code></pre>
1150 </section>
1151
1152 <!-- 9. SMOKE TEST -->
1153 <section id="smoke-test">
1154 <h2><span class="num">09</span> Smoke test</h2>
1155 <p>Before opening to the world, sanity-check from the command line.</p>
1156
1157 <h3>9.1 — Perl syntax check</h3>
1158 <pre data-label="bash"><code><span class="p">$</span> <span class="k">cd</span> /var/www/vhosts/webstls.com/httpdocs
1159<span class="p">$</span> <span class="k">for</span> f <span class="k">in</span> *.cgi; <span class="k">do</span> <span class="k">perl</span> -I . -c <span class="s">"$f"</span>; <span class="k">done</span>
1160
1161<span class="c"># Each line should end with: ... syntax OK</span></code></pre>
1162
1163 <h3>9.2 — Database reachability</h3>
1164 <pre data-label="bash"><code><span class="p">$</span> <span class="k">perl</span> -I . -e <span class="s">'use MODS::DBConnect; my $dbh = MODS::DBConnect-&gt;new-&gt;db_connect; print $dbh ? "DB ok\n" : "DB FAIL\n"'</span></code></pre>
1165
1166 <h3>9.3 — Browser check</h3>
1167 <ul class="check-list">
1168 <li>Visit <code>https://webstls.com/index.cgi</code> → marketing page loads</li>
1169 <li>Visit <code>https://webstls.com/dashboard.cgi</code> → bounces to <code>/login.cgi</code> (auth working)</li>
1170 <li>Visit <code>https://webstls.com/login.cgi</code> → form renders</li>
1171 <li>View page source: assets load from <code>/assets/css/site.css</code> &amp; <code>/assets/javascript/site.js</code></li>
1172 <li>DevTools Network tab: no 404s on graphs.js, fonts, or images</li>
1173 </ul>
1174 </section>
1175
1176 <!-- 10. FIRST USER -->
1177 <section id="first-user">
1178 <h2><span class="num">10</span> Create the first user</h2>
1179 <p>Easiest path: visit <code>/signup.cgi</code> in a browser, fill the form, submit. The handler hashes the password (Argon2id if installed, otherwise PBKDF2-SHA256), writes a row into <code>users</code>, and signs you in immediately.</p>
1180
1181 <p>Want to be admin from the start? After creating the account, bump your <code>plan_tier</code> and <code>trust_level</code>:</p>
1182
1183 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">USE</span> WEBSTLS;
1184<span class="p">mysql&gt;</span> <span class="k">UPDATE</span> users
1185 <span class="k">SET</span> plan_tier = <span class="s">'studio'</span>,
1186 trust_level = <span class="n">10</span>,
1187 email_verified_at = <span class="k">NOW</span>()
1188 <span class="k">WHERE</span> email = <span class="s">'you@webstls.com'</span>;</code></pre>
1189
1190 <p>Now <code>/admin.cgi</code> is available — that page exposes feature flags, integration health, system status, and the audit log.</p>
1191 </section>
1192
1193 <!-- 11. EMAIL -->
1194 <section id="email">
1195 <h2><span class="num">11</span> Transactional email <span style="color:var(--col-text-3);font-weight:400;font-size:14px">(optional)</span></h2>
1196 <p>WebSTLs has no email plumbing wired in yet — that's deliberate. The spec calls for Postmark or AWS SES; pick one and add a small <code>MODS::WebSTLs::Mail</code> wrapper. Hooks where you'll want to send mail:</p>
1197 <ul>
1198 <li>signup → "verify your email"</li>
1199 <li>password reset</li>
1200 <li>order paid → buyer receipt + creator notification</li>
1201 <li>integration degraded → creator alert</li>
1202 <li>weekly performance digest</li>
1203 </ul>
1204 <p>Until then, the signup flow auto-verifies (<code>email_verified_at = NOW()</code> on insert) so you can test end-to-end without a mail provider.</p>
1205 </section>
1206
1207 <!-- 12. STRIPE -->
1208 <section id="stripe">
1209 <h2><span class="num">12</span> Stripe <span style="color:var(--col-text-3);font-weight:400;font-size:14px">(optional, for billing)</span></h2>
1210 <p>The schema has columns ready for Stripe — <code>users.stripe_customer_id</code>, <code>users.stripe_subscription_id</code>, <code>storefronts.stripe_account_id</code>, <code>orders.stripe_payment_intent_id</code>, etc. To wire it up:</p>
1211 <ol>
1212 <li>Add your publishable key to <code>MODS/WebSTLs/Config.pm</code> → <code>stripe_publishable</code></li>
1213 <li>Store the secret key in an environment variable, not in code (<code>SetEnv STRIPE_SECRET_KEY ...</code> in the Apache vhost)</li>
1214 <li>Build <code>MODS::WebSTLs::Stripe</code> for SaaS subscriptions (Stripe Billing) and creator payouts (Stripe Connect Express)</li>
1215 <li>Add a <code>webhook.cgi</code> to receive Stripe events into <code>orders</code> / <code>refunds</code> / <code>disputes</code></li>
1216 </ol>
1217 </section>
1218
1219 <!-- 13. R2 -->
1220 <section id="r2">
1221 <h2><span class="num">13</span> R2 / B2 file storage <span style="color:var(--col-text-3);font-weight:400;font-size:14px">(optional)</span></h2>
1222 <p><code>model_files</code> has a <code>storage_provider ENUM('r2','b2','s3','local')</code> column and a <code>storage_key</code> string. For a pilot you can use <code>local</code> and store files under a non-web-readable directory (e.g. <code>/var/webstls/files/</code>). When you're ready to scale:</p>
1223 <ul>
1224 <li>Sign up for Cloudflare R2 (zero egress fees, S3-compatible)</li>
1225 <li>Create a bucket — match the name in <code>MODS/WebSTLs/Config.pm → r2_bucket</code></li>
1226 <li>Add <code>R2_ACCESS_KEY_ID</code> / <code>R2_SECRET_ACCESS_KEY</code> as environment variables</li>
1227 <li>Build <code>MODS::WebSTLs::Storage</code> with the same interface for both <code>local</code> and <code>r2</code> backends — the existing <code>storage_provider</code> column lets you migrate one model at a time</li>
1228 </ul>
1229 </section>
1230
1231 <!-- 14. TROUBLESHOOTING -->
1232 <section id="troubleshooting">
1233 <h2><span class="num">14</span> Troubleshooting</h2>
1234
1235 <h3>Where do CGI errors actually go?</h3>
1236 <p>NOT to <code>/var/log/httpd/error_log</code> — that's the server-wide log and it only has SSL/startup noise. The <strong>vhost-specific log</strong> is the one with your CGI errors:</p>
1237 <pre data-label="bash"><code><span class="p">$</span> <span class="k">tail</span> -50 /var/www/vhosts/system/webstls.com/logs/error_log <span class="c"># Plesk</span>
1238<span class="p">$</span> <span class="k">tail</span> -50 /var/log/httpd/webstls.com-error.log <span class="c"># bare Apache</span></code></pre>
1239 <p>If still no useful info, run the CGI from the shell — that bypasses Apache and prints any Perl error directly:</p>
1240 <pre data-label="bash"><code><span class="p">$</span> <span class="k">cd</span> /var/www/vhosts/webstls.com/httpdocs
1241<span class="p">$</span> <span class="k">perl</span> login.cgi</code></pre>
1242
1243 <h3>500 on every page — ".htaccess: <Directory not allowed here"</h3>
1244 <p>Caused by an <code>.htaccess</code> that contains <code>&lt;Directory&gt;</code> blocks. <code>&lt;Directory&gt;</code> and <code>&lt;VirtualHost&gt;</code> are <strong>illegal in .htaccess</strong> — they're for the main Apache config only. Apache rejects the entire .htaccess and 500s every request to that vhost. Use the .htaccess template in <a href="#apache">section 7</a> — it sticks to <code>Options</code>, <code>AddHandler</code>, <code>FilesMatch</code>, and <code>Header</code>, all of which are legal.</p>
1245
1246 <h3>500 on every page — generic Perl compile error</h3>
1247 <ul>
1248 <li>Run <code>perl -I . -cw login.cgi</code> on the server — surfaces compile errors that the browser hides</li>
1249 <li>Confirm <code>.cgi</code> shebang matches <code>which perl</code></li>
1250 <li>Confirm file uploaded with Unix line endings, not CRLF: <code>dos2unix *.cgi MODS/*.pm MODS/WebSTLs/*.pm TEMPLATES/webstls_*.html .htaccess MODS/.htaccess TEMPLATES/.htaccess</code></li>
1251 <li>Confirm <code>chmod 755 *.cgi</code> so they're executable</li>
1252 </ul>
1253
1254 <h3>"Can't locate MODS/Login.pm in @INC"</h3>
1255 <p>The <code>use lib '/var/www/vhosts/webstls.com/httpdocs';</code> line at the top of each .cgi must point at your actual web root. Verify with <code>head -15 dashboard.cgi</code>. Same goes for <code>MODS/WebSTLs/Wrapper.pm</code> and <code>MODS/Template.pm</code>.</p>
1256
1257 <h3>"Can't locate MODS/Urls.pm" or similar</h3>
1258 <p>This appeared in a deploy when an old <code>MODS/Template.pm</code> tried to <code>use MODS::Urls</code> at compile time. Recent Template.pm uses <code>require</code> inside an <code>eval</code> so missing optional MODs are tolerated. If you see this, you have an outdated Template.pm — re-upload from the source bundle.</p>
1259
1260 <h3>Signup says "That email is already in use, or an account error occurred"</h3>
1261 <p>Three possible causes; debug from the shell:</p>
1262 <pre data-label="bash"><code><span class="p">#</span> <span class="c"># Is the email actually a duplicate?</span>
1263<span class="p">$</span> <span class="k">perl</span> -I . -MMODS::DBConnect -e <span class="s">'</span>
1264 my $dbh = MODS::DBConnect->new->db_connect;
1265 my $rows = $dbh->selectall_arrayref(<span class="s">"SELECT id, email FROM `WEBSTLs`.users"</span>, { Slice => {} });
1266 use Data::Dumper; print Dumper($rows);
1267<span class="s">'</span>
1268
1269<span class="p">#</span> <span class="c"># Is password hashing failing silently?</span>
1270<span class="p">$</span> <span class="k">perl</span> -e <span class="s">'use Digest::SHA qw(sha256_hex); print "sha256_hex ok\n"'</span>
1271
1272<span class="p">#</span> <span class="c"># Run signup directly — surfaces the actual DBI error if the INSERT fails</span>
1273<span class="p">$</span> REMOTE_ADDR=127.0.0.1 <span class="k">perl</span> -I . -MMODS::Login -e <span class="s">'</span>
1274 my $login = MODS::Login->new;
1275 my $id = $login->signup(<span class="s">"test\@example.com"</span>, <span class="s">"testpassword"</span>, <span class="s">"Test User"</span>, <span class="s">"free"</span>);
1276 print defined $id ? <span class="s">"OK id=$id\n"</span> : <span class="s">"signup returned undef\n"</span>;
1277<span class="s">'</span></code></pre>
1278
1279 <h3>"Access denied for user 'X'@'%' to database 'Y'"</h3>
1280 <p>The DB user exists but lacks grants on the database — <strong>or</strong> you've got a case mismatch. If <code>MODS/DBConnect.pm</code> says <code>'WEBSTLS'</code> but Plesk created <code>'WEBSTLs'</code>, those are different databases on Linux.</p>
1281 <pre data-label="bash"><code><span class="p">$</span> <span class="k">grep</span> -n WEBSTL MODS/DBConnect.pm MODS/WebSTLs/Config.pm MODS/Config.pm
1282<span class="p">$</span> <span class="k">mysql</span> -u root -p -e <span class="s">"SHOW DATABASES LIKE '%ebstls%'"</span></code></pre>
1283 <p>The case in the SQL output must match exactly what's in the three Config files.</p>
1284
1285 <h3>SQL Error 1067, 1071, 1141, 1214, 1293 during schema load</h3>
1286 <p>All of these are old-MariaDB compatibility issues that the shipped schema works around:</p>
1287 <ul>
1288 <li><strong>1067 "Invalid default value for 'created_at'"</strong> — pre-MySQL-5.6.5 doesn't allow <code>CURRENT_TIMESTAMP</code> default on <code>DATETIME</code>. Schema uses <code>TIMESTAMP</code>.</li>
1289 <li><strong>1071 "Specified key was too long; max key length is 767 bytes"</strong> — old InnoDB index limit. Indexed VARCHARs are at 191 chars (191×4 bytes &lt; 767).</li>
1290 <li><strong>1141 "There is no such grant defined"</strong> — happens when running <code>SHOW GRANTS FOR 'X'@'localhost'</code> for a user that only exists at <code>'X'@'%'</code>. Plesk creates wildcard-host users only.</li>
1291 <li><strong>1214 "The used table type doesn't support FULLTEXT"</strong> — MariaDB 5.5 InnoDB doesn't support FULLTEXT. Schema has no FULLTEXT indexes.</li>
1292 <li><strong>1293 "Incorrect table definition; only one TIMESTAMP column with CURRENT_TIMESTAMP"</strong> — pre-MySQL-5.6.5 limit. Schema uses <code>BEFORE INSERT</code> / <code>BEFORE UPDATE</code> triggers instead.</li>
1293 </ul>
1294 <p>If you see one of these, your schema file is older than the deploy fixes. Re-export it from this bundle.</p>
1295
1296 <h3>Login form posts but always says "Email or password is incorrect"</h3>
1297 <ul>
1298 <li>Hash format mismatch — if Crypt::Argon2 was installed when you signed up but isn't now (or vice versa), the stored hash format won't match what Login.pm's verify path can read. Re-create the user.</li>
1299 <li>Cookie blocked: with <code>secure_cookies =&gt; 1</code> and plain HTTP the browser silently drops the Set-Cookie. Either enable HTTPS or set <code>secure_cookies =&gt; 0</code> in <code>MODS/WebSTLs/Config.pm</code> for testing.</li>
1300 <li>Account inactive — Login.pm only authenticates when <code>users.account_status = 'active'</code>. Plesk-created users get <code>'pending_verification'</code> by default; flip with <code>UPDATE users SET account_status='active' WHERE email=...</code>.</li>
1301 </ul>
1302
1303 <h3>Sidebar / topbar missing on logged-in pages</h3>
1304 <p>That means Wrapper.pm is failing to load <code>TEMPLATES/webstls_wrapper.html</code>. Check the file exists and Apache can read it (it shouldn't be web-accessible — that's what <code>TEMPLATES/.htaccess</code> with <code>Require all denied</code> is for — but the local Perl process needs read access).</p>
1305
1306 <h3>Database errors not landing anywhere</h3>
1307 <p>DB errors append to <code>MODS/DB_ERRORS.log</code> and a one-line summary goes to Apache's vhost <code>error_log</code> via STDERR. Make sure the log file exists and is writable by Apache (step 8). If the log is empty after a known failure, check <code>ls -l MODS/DB_ERRORS.log</code> permissions before anything else.</p>
1308
1309 <h3>"Template eval error: …" in the page body</h3>
1310 <p>MODS::Template generates Perl source and evals it. When the generated code is malformed, you'll see one of these in the rendered HTML:</p>
1311 <ul>
1312 <li><code>Template eval error: syntax error at (eval N) line M, near "…"</code></li>
1313 <li><code>Template eval error: Unrecognized character \xE2; marked by &lt;-- HERE</code></li>
1314 </ul>
1315 <p>The template engine has <strong>four known bugs</strong> we've worked around in this codebase:</p>
1316
1317 <p><strong>1. <code>[if:]</code> blocks inside <code>[loop:]</code> blocks generate malformed Perl</strong>, especially when the body contains:</p>
1318 <ul>
1319 <li>negated conditions like <code>[if:!$loop1.field]</code></li>
1320 <li>multi-byte UTF-8 characters (★ — → arrows, etc.)</li>
1321 <li>complex HTML with both single and double quotes</li>
1322 </ul>
1323 <p><strong>Workaround:</strong> precompute the conditional HTML in the .cgi handler, pass it as a single template variable. Examples already in the codebase:</p>
1324
1325 <pre data-label="storefront.cgi (Perl)"><code><span class="c"># Build status pill once, hand to template as ready-to-render HTML</span>
1326<span class="v">status_pill</span> =&gt; <span class="p">(</span><span class="v">$_</span><span class="p">-&gt;{</span><span class="v">status</span><span class="p">}</span> <span class="k">eq</span> <span class="s">'published'</span><span class="p">)</span>
1327 <span class="p">?</span> <span class="s">'&lt;span class="pill success"&gt;Published&lt;/span&gt;'</span>
1328 <span class="p">:</span> <span class="s">'&lt;span class="pill warning"&gt;Draft&lt;/span&gt;'</span><span class="p">,</span></code></pre>
1329
1330 <pre data-label="webstls_storefront.html (template)"><code><span class="c">&lt;!-- Just one variable, no [if:] inside the [loop:] --&gt;</span>
1331&lt;td&gt;<span class="v">$loop1.status_pill</span>&lt;/td&gt;</code></pre>
1332
1333 <p><strong>2. <code>[else]</code> tag pairing is unreliable.</strong> A pattern like <code>[if:cond]A[else]B[/if]</code> sometimes generates Perl that's missing a closing brace. Prefer two adjacent <code>[if:]</code> blocks (<code>[if:cond]A[/if][if:!cond]B[/if]</code>) when the if/else is at the top level — or, better, precompute as in workaround #1.</p>
1334
1335 <p><strong>3. <code>[loop:@tvars.X]</code> generates malformed Perl when the array has rows.</strong> The outside-loop handler in <code>MODS/Template.pm</code> emits <code>@{$tvars->{tvars}}->{X}</code> instead of <code>@{$tvars->{X}}</code>. With an empty array nothing runs and you don't notice; the moment that array gets populated, the page dies with <em>"Can't use an undefined value as a HASH reference"</em>. <strong>Workaround:</strong> always write <code>[loop:@X]</code> (no <code>tvars.</code> prefix) — Template.pm prepends <code>$tvars-&gt;</code> on its own and the resulting Perl is correct.</p>
1336
1337 <pre data-label="template — wrong vs right"><code><span class="c">&lt;!-- WRONG: dies on populated arrays --&gt;</span>
1338[loop:<span class="o">@tvars.pages</span>] ... [/loop]
1339
1340<span class="c">&lt;!-- RIGHT: works regardless of array size --&gt;</span>
1341[loop:<span class="o">@pages</span>] ... [/loop]</code></pre>
1342
1343 <p><strong>4. Top-level scalars like <code>$store_id</code> get reinterpreted as <code>$loop1-&gt;{store_id}</code> inside <code>[loop:]</code> bodies</strong> — meaning they evaluate to <em>undef</em> (since <code>$loop1</code> is a row hash without that key) and you get URLs like <code>?id=&page=shop</code> with empty values. <strong>Workaround:</strong> use the explicit <code>$tvars.store_id</code> syntax inside the loop body. Template.pm has a global pre-pass that converts <code>$tvars.X</code> to <code>$tvars-&gt;{X}</code> regardless of context, so it survives the loop reinterpretation.</p>
1344
1345 <pre data-label="template — wrong vs right"><code><span class="c">&lt;!-- Inside [loop:@pages] --&gt;</span>
1346
1347<span class="c">&lt;!-- WRONG: $store_id becomes $loop1-&gt;{store_id} = undef --&gt;</span>
1348&lt;a href="/store.cgi?id=<span class="o">$store_id</span>&page=<span class="o">$loop1.slug</span>"&gt;...&lt;/a&gt;
1349
1350<span class="c">&lt;!-- RIGHT: $tvars.store_id stays bound to the top-level tvars --&gt;</span>
1351&lt;a href="/store.cgi?id=<span class="o">$tvars.store_id</span>&page=<span class="o">$loop1.slug</span>"&gt;...&lt;/a&gt;</code></pre>
1352
1353 <p><strong>Use this rule:</strong> inside any <code>[loop:]</code> body, <strong>always use <code>$tvars.X</code></strong> for variables that come from the top-level template context, and <strong>always use <code>$loopN.X</code></strong> for variables from the current row. Bare <code>$X</code> is ambiguous and will be reinterpreted as the loop row's field.</p>
1354
1355 <p><strong>How to debug:</strong> the <code>(eval N) line M</code> in the error refers to the generated Perl source — you can't see that file directly, but the "near" snippet usually identifies the surrounding HTML. Look at the template's <code>[loop:]</code> bodies first; that's where 95% of template eval errors live.</p>
1356 </section>
1357
1358 <!-- 15. FLAGS -->
1359 <section id="flags">
1360 <h2><span class="num">15</span> Feature flags &amp; staged reveal</h2>
1361 <p>WebSTLs ships with all modules built but most reveal-gated. The default flag values in <code>feature_flags</code> control what the UI shows.</p>
1362
1363 <table>
1364 <thead><tr><th>Flag</th><th>Default</th><th>Effect</th></tr></thead>
1365 <tbody>
1366 <tr><td>module.publishing</td><td>ON</td><td>Cross-platform publishing module visible</td></tr>
1367 <tr><td>module.storefront</td><td>OFF</td><td>Hosted storefront — turn on per-account when ready</td></tr>
1368 <tr><td>module.optimization</td><td>OFF</td><td>A/B + price tools — Phase 4 reveal</td></tr>
1369 <tr><td>module.pooled_data</td><td>OFF</td><td>Cross-store benchmarking — needs 100+ stores</td></tr>
1370 <tr><td>module.audience</td><td>ON</td><td>Buyer accounts &amp; comment inbox</td></tr>
1371 <tr><td>module.team</td><td>OFF</td><td>Studio-tier team collaboration</td></tr>
1372 <tr><td>feature.auto_render</td><td>OFF</td><td>AI marketing renders — Phase 2</td></tr>
1373 <tr><td>feature.ai_descriptions</td><td>OFF</td><td>AI description generator — Phase 2</td></tr>
1374 <tr><td>feature.slice_preview</td><td>OFF</td><td>Auto-slicing previews — Phase 2</td></tr>
1375 <tr><td>killswitch.publishing</td><td>OFF</td><td>Emergency disable — flip to 1 to halt all publishing</td></tr>
1376 </tbody>
1377 </table>
1378
1379 <p>To flip a flag globally:</p>
1380 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">UPDATE</span> feature_flags
1381 <span class="k">SET</span> default_value = <span class="n">1</span>
1382 <span class="k">WHERE</span> flag_key = <span class="s">'module.storefront'</span>;</code></pre>
1383
1384 <p>To override per-user:</p>
1385 <pre data-label="mysql"><code><span class="p">mysql&gt;</span> <span class="k">INSERT INTO</span> account_flag_overrides
1386 (user_id, flag_key, override_value, set_by, set_at)
1387 <span class="k">VALUES</span> (<span class="n">42</span>, <span class="s">'module.optimization'</span>, <span class="n">1</span>, <span class="k">NULL</span>, <span class="k">NOW</span>());</code></pre>
1388
1389 <p>The Admin Console (<code>/admin.cgi</code>) has a UI for both — start there.</p>
1390 </section>
1391
1392 <div class="doc-foot">
1393 <p>That's everything. If a step blows up, the error log is your friend — Perl errors are usually obvious once you read them.</p>
1394 <p>Last updated: with the deploy refactor that pushed page bodies into <code>TEMPLATES/webstls_*.html</code> and split chart code into <code>graphs.js</code>.</p>
1395 </div>
1396
1397 </main>
1398 </div>
1399</body>
1400</html>
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help