Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01672cf6ee | ||
|
|
378aff24d0 | ||
|
|
a9a91a25d4 | ||
|
|
a1488da2d8 | ||
|
|
060b0f1733 | ||
|
|
e18c322538 | ||
|
|
7e0802f913 | ||
|
|
86f3a977b9 | ||
|
|
a0c22f22ca | ||
|
|
ab16b8f935 | ||
|
|
daca86dc43 | ||
|
|
8cebe20981 | ||
|
|
9fa366a0ac | ||
|
|
044ded45b3 | ||
|
|
fb66e17b50 | ||
|
|
be37348a8c | ||
|
|
937a114e30 | ||
|
|
4e2e603449 | ||
|
|
cdc3475f61 | ||
|
|
55619aa04e | ||
|
|
0dee1ea173 | ||
|
|
53f1ec5de7 | ||
|
|
12529e0cd0 | ||
|
|
4cf52e04c1 | ||
|
|
730a936137 | ||
|
|
f0819eb3a3 | ||
|
|
249b32b5d9 | ||
|
|
da60e08e20 | ||
|
|
f41a6bdb1e | ||
|
|
63029ddefd | ||
|
|
8e595d0f93 | ||
|
|
30a1963bea | ||
|
|
2645f778b2 | ||
|
|
89a46451f4 | ||
|
|
010306f01f | ||
|
|
0da3ea5768 | ||
|
|
383e46cd0c | ||
|
|
3503bb22b8 | ||
|
|
b3f45bfc0d | ||
|
|
6aa9105460 | ||
|
|
618b01e4bd | ||
|
|
98b50af6be | ||
|
|
3d5e75aa70 | ||
|
|
f378b4f58d | ||
|
|
ffb2dbe15a | ||
|
|
62b681bad1 | ||
|
|
ca88623486 | ||
|
|
7ecf1b878a | ||
|
|
3a00a2d517 | ||
|
|
ca650f69c0 | ||
|
|
1a61c30674 | ||
|
|
493dab88ba | ||
|
|
7f25f9ae4d | ||
|
|
ef6b54b2f1 | ||
|
|
756179d79f | ||
|
|
2c86da97c7 | ||
|
|
a9554212a1 | ||
|
|
34fff3e4e9 | ||
|
|
b3108b2c7c | ||
|
|
203400a731 | ||
|
|
a031722ec1 | ||
|
|
e5ec418b9f | ||
|
|
184395695f | ||
|
|
1314587ba1 | ||
|
|
93c716f876 | ||
|
|
dfd9612a6b | ||
|
|
f0fce15dee | ||
|
|
1ccb54a466 | ||
|
|
e608d28ec3 | ||
|
|
da0409f09c | ||
|
|
cf31d85ece | ||
|
|
6b56c1f4db |
@@ -16,3 +16,8 @@ Thumbs.db
|
||||
# logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# og/banner gen scratch\.og/
|
||||
|
||||
# og/banner gen scratch
|
||||
.og/
|
||||
|
||||
@@ -38,6 +38,7 @@ src/
|
||||
lang.ts # derives en/zh from folder path
|
||||
docs/
|
||||
content-guide.md # what to write, what to avoid (READ before writing a post)
|
||||
post-guideline.md # HOW to write it: title case, locale, frontmatter, publish flow
|
||||
design-guide.md # what the design must have, what to avoid (READ before touching UI)
|
||||
seo-reference.md # E-E-A-T / name identity, SEO + GEO checklist, syndication
|
||||
ops-runbook.md # pipeline, publish steps, build pitfalls, health checks
|
||||
@@ -49,6 +50,7 @@ docs/
|
||||
| Doc | When to read |
|
||||
|---|---|
|
||||
| [`docs/content-guide.md`](docs/content-guide.md) | Before writing/planning any blog post — categories, post types, the "hard job → post" template, and the anti-patterns. |
|
||||
| [`docs/post-guideline.md`](docs/post-guideline.md) | Before writing/editing a post — title case, locale folder structure, frontmatter, publish flow. |
|
||||
| [`docs/design-guide.md`](docs/design-guide.md) | Before changing theme, layout, typography, color, or SEO markup. |
|
||||
| [`docs/seo-reference.md`](docs/seo-reference.md) | Before adding posts/`<head>` markup or debugging search visibility. |
|
||||
| [`docs/ops-runbook.md`](docs/ops-runbook.md) | Before debugging CI/CD, deployment, or hosting. |
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post metadata, read straight from the markdown at config-eval time.
|
||||
// The sitemap is generated in `astro:build:done`, so the config layer resolves
|
||||
// BEFORE the content collection exists - reading the files directly is the
|
||||
// supported way to get post dates into `serialize()`.
|
||||
// ---------------------------------------------------------------------------
|
||||
const POSTS_DIR = fileURLToPath(new URL('./src/content/posts', import.meta.url));
|
||||
|
||||
/** Pull `key: value` pairs out of a markdown frontmatter block. */
|
||||
function frontmatter(file) {
|
||||
const raw = readFileSync(file, 'utf-8');
|
||||
const end = raw.indexOf('---', 3);
|
||||
const block = end === -1 ? raw : raw.slice(0, end);
|
||||
const out = {};
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Za-z][A-Za-z0-9_]*):\s*(.+)$/);
|
||||
if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Normalize a frontmatter date (2026-09-04) to a sitemap lastmod (W3C). */
|
||||
function isoDate(d) {
|
||||
if (!d) return undefined;
|
||||
const t = Date.parse(d);
|
||||
return Number.isNaN(t) ? undefined : new Date(t).toISOString();
|
||||
}
|
||||
|
||||
// slug -> { en, zh } ISO dates. EN and ZH twins share a slug, so one entry
|
||||
// serves both localized URLs; lastmod = the newest of the two.
|
||||
const postDates = new Map();
|
||||
{
|
||||
const collect = (dir, key) => {
|
||||
for (const file of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!file.isFile() || !file.name.endsWith('.md')) continue;
|
||||
const slug = file.name.slice(0, -3);
|
||||
const fm = frontmatter(join(dir, file.name));
|
||||
const date = isoDate(fm.updatedDate ?? fm.pubDate);
|
||||
if (!date) continue;
|
||||
const entry = postDates.get(slug) ?? {};
|
||||
entry[key] = date;
|
||||
postDates.set(slug, entry);
|
||||
}
|
||||
};
|
||||
try {
|
||||
collect(POSTS_DIR, 'en');
|
||||
collect(join(POSTS_DIR, 'zh'), 'zh');
|
||||
} catch (e) {
|
||||
console.warn('[sitemap] could not read post dates:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
@@ -12,5 +67,47 @@ export default defineConfig({
|
||||
prefixDefaultLocale: false,
|
||||
},
|
||||
},
|
||||
integrations: [sitemap()],
|
||||
integrations: [
|
||||
sitemap({
|
||||
// lastmod lets Google re-crawl content that actually changed instead of
|
||||
// falling back to its own heuristics. hreflang alternates in the sitemap
|
||||
// are treated as more reliable than the in-page <link rel="alternate">
|
||||
// tags when the two disagree, so both are emitted.
|
||||
serialize(item) {
|
||||
const path = new URL(item.url).pathname;
|
||||
const post = path.match(/^\/posts\/(?:zh\/)?([^/]+)\/$/);
|
||||
|
||||
if (post) {
|
||||
// Post pages get lastmod; their alternates are the EN/ZH twins.
|
||||
const dates = postDates.get(post[1]);
|
||||
if (dates) item.lastmod = dates.en > dates.zh ? dates.en : dates.zh ?? dates.en;
|
||||
|
||||
item.links = [
|
||||
{ lang: 'en', url: `https://blog.hoelee.com/posts/${post[1]}/` },
|
||||
{ lang: 'zh', url: `https://blog.hoelee.com/posts/zh/${post[1]}/` },
|
||||
];
|
||||
return item;
|
||||
}
|
||||
|
||||
// Non-post routes: pair the two landing/section pages by pathname.
|
||||
// Exception: /posts/ has no /zh/posts/ twin - the Chinese post listing
|
||||
// IS the /zh/ homepage (the ZH nav "Posts" points there), so that pair
|
||||
// is declared by hand rather than derived.
|
||||
const MANUAL = {
|
||||
'/posts/': { en: '/posts/', zh: '/zh/' },
|
||||
'/zh/': { en: '/posts/', zh: '/zh/' },
|
||||
};
|
||||
|
||||
const src = /^\/zh\/(.*)$/.exec(path);
|
||||
const pair = MANUAL[path]
|
||||
?? (src ? { en: `/${src[1]}`, zh: path } : { en: path, zh: `/zh/${path.slice(1)}` });
|
||||
|
||||
item.links = [
|
||||
{ lang: 'en', url: `https://blog.hoelee.com${pair.en}` },
|
||||
{ lang: 'zh', url: `https://blog.hoelee.com${pair.zh}` },
|
||||
];
|
||||
return item;
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ draft: false
|
||||
## 8. i18n policy
|
||||
|
||||
- **English is primary and non-negotiable** — the whole SEO strategy targets English queries.
|
||||
- **Chinese (zh):** translate only the 2–3 best case studies. Cheap differentiation, opens zh-SG/zh-MY search.
|
||||
- **Chinese (zh): translate every post.** Same filename in `posts/zh/` (auto language-switch). Opens zh-SG/zh-MY search and covers bilingual recruiters.
|
||||
- **Malay: skip for v1** — no dev-audience demand (the main site already has a dangling Malay config; don't repeat it).
|
||||
|
||||
### File layout (enforced by code)
|
||||
|
||||
@@ -63,3 +63,18 @@ Overflow diagnosis (real numbers, not screenshot guessing): check `scrollWidth >
|
||||
- `curl https://blog.hoelee.com/sitemap.xml` → lists all published posts.
|
||||
- `curl https://blog.hoelee.com/rss.xml` → non-empty.
|
||||
- DNS: `blog.hoelee.com` resolves through Cloudflare (proxy enabled).
|
||||
|
||||
## Pitfall: premature poll caches a 16-day 404
|
||||
|
||||
The origin serves `Cache-Control: max-age=1382400` (16 days), and Cloudflare caches
|
||||
negative (404) responses too. If you curl a freshly-deployed URL **before the CI deploy
|
||||
finishes**, Cloudflare caches that 404 for 16 days — the page itself returns 200 but its
|
||||
`og`/`banner` PNGs 404 with `cf-cache-status: HIT`.
|
||||
|
||||
- **Detect:** the bare URL 404s but `?v=<timestamp>` (cache-buster) returns 200 → origin is
|
||||
fine, edge cache is stale.
|
||||
- **Fix:** purge the Cloudflare zone cache. Zone-level token (`Zone.Cache Purge` perm, lives
|
||||
in the cloudflare-pages-deploy skill) → `POST /zones/8c8b2359766ac853602b91dd851c630e/purge_cache`
|
||||
with `{"purge_everything":true}`. See that skill for the exact curl.
|
||||
- **Prevent:** after `git push`, `sleep` ~60–90s before the first bare-URL check, or validate
|
||||
with `?cb=$(date +%s)` first so you never register a 404 into edge cache.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Post-Writing Guideline
|
||||
|
||||
A single reference for writing a post on blog.hoelee.com. Read this **before** creating or editing any post. It consolidates the style, locale, and frontmatter rules — the "how to write it correctly the first time" checklist.
|
||||
|
||||
> Broader *what-to-write* strategy (categories, post types, cadence, anti-patterns) lives in `content-guide.md`. This file is the *how* — mechanics and style.
|
||||
|
||||
---
|
||||
|
||||
## 1. File location & language (enforced by code)
|
||||
|
||||
| Language | Path | URL |
|
||||
|---|---|---|
|
||||
| English | `src/content/posts/<slug>.md` | `/posts/<slug>/` |
|
||||
| Chinese | `src/content/posts/zh/<slug>.md` | `/posts/zh/<slug>/` |
|
||||
|
||||
- **Do NOT set a `lang:` field** in frontmatter — language is derived from the folder.
|
||||
- English is primary. Chinese is selective (2–3 flagship case studies). No Malay.
|
||||
- To make a Chinese translation of a post, give it the **same filename** in the `zh/` folder — the language switcher auto-links them. If filenames differ, add a `translation: "zh/<other-slug>"` field to link them manually.
|
||||
|
||||
---
|
||||
|
||||
## 2. Title style — Title Case
|
||||
|
||||
All English post titles use **Title Case** (capitalize the first letter of every significant word):
|
||||
|
||||
- ✅ `How I Host This Blog: Astro, Gitea Actions, and Self-Hosted CI/CD`
|
||||
- ✅ `Hello, World — About This Blog`
|
||||
- ❌ `How I host this blog: ...`
|
||||
|
||||
Exceptions stay lowercase (articles, prepositions, conjunctions — but our house style capitalizes them after punctuation like `—` or `:` for a clean look): "About", "This", "And", "and" are all acceptable; be **consistent**.
|
||||
|
||||
Chinese titles need no capitalization change.
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontmatter shape
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "How I Built the DigiKedai Telegram AI Bot" # Title Case
|
||||
description: "A ~155-char meta description with the target keyword."
|
||||
pubDate: 2026-09-06
|
||||
updatedDate: 2026-09-10 # optional, when revised
|
||||
category: case-studies # one of the 7 below
|
||||
tags: ["telegram", "n8n", "docker", "cloudflare"]
|
||||
translation: "zh/how-i-built-digikedai-bot" # optional, only if slug differs from zh counterpart
|
||||
ogImage: "/og/digikedai-bot.png" # optional, 1200×630 custom image
|
||||
draft: false # true = hidden from build
|
||||
---
|
||||
```
|
||||
|
||||
### Categories (the only 7 allowed)
|
||||
`engineering` · `devops` · `ai` · `web3` · `tutorials` · `case-studies` · `notes`
|
||||
|
||||
---
|
||||
|
||||
## 4. The "hard job → post" template
|
||||
|
||||
When you finish a difficult piece of work, use this shape — it's simultaneously a tutorial, a case study, and a proof-of-expertise:
|
||||
|
||||
```
|
||||
① The problem → phrased as the searchable question a learner would type
|
||||
② What I tried & why it failed → the debugging story (no one else can copy this)
|
||||
③ The fix → runnable code/config, explained
|
||||
④ What I'd do differently → shows judgment
|
||||
⑤ The result → one quantified outcome
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Language & identity rules
|
||||
|
||||
- **Business framing:** website design & development is the primary business; email hosting is a **side offering** — never describe it as "an email-hosting business".
|
||||
- **Name:** "Lee Teong Hoe" / "Mr Hoelee" — identical across blog, LinkedIn, GitHub, git.hoelee.com.
|
||||
- **No overclaiming**, especially Web3 (learning projects, not production DeFi).
|
||||
|
||||
---
|
||||
|
||||
## 6. Code blocks
|
||||
|
||||
- Wrap code in fenced blocks with the language tag (```yaml, ```bash, ```ts).
|
||||
- Keep code/commands in their **original language** (don't translate code or commands inside a Chinese post).
|
||||
- The copy button is added automatically by the layout — no action needed.
|
||||
|
||||
---
|
||||
|
||||
## 7. Publish flow
|
||||
|
||||
1. Write the `.md` file in the correct folder (see §1).
|
||||
2. `npm run build` locally to confirm it compiles (optional but recommended).
|
||||
3. Commit + push to both remotes:
|
||||
```bash
|
||||
git push origin main && git push github main
|
||||
```
|
||||
4. Gitea Actions CI builds and deploys automatically; verify with `curl -I https://blog.hoelee.com/posts/<slug>/` → 200.
|
||||
|
||||
## 8. i18n sync — Chinese translation (do this automatically, every post)
|
||||
|
||||
Every post gets a Chinese translation, **every time**, without being asked:
|
||||
|
||||
1. **Translate every post.** Any new post gets a Chinese version at `src/content/posts/zh/<same-slug>.md` — **same filename**, so the language switcher auto-links the two (no `translation:` field needed).
|
||||
2. **Keep EN and ZH in sync.** If you edit the English post (add a section, fix a fact, update a link), make the **same edit** to the Chinese version in the same commit. Never let the two versions drift.
|
||||
3. **Translate the *frontmatter* too** — title and description go to Chinese, but `category`, `tags`, and `pubDate` stay identical to the English post (they're data, not prose).
|
||||
4. **Code/commands stay in English** inside the Chinese post (see §6) — only the prose around them is translated.
|
||||
5. **Every case study ends with a hire CTA** (in the post body, not frontmatter): a "Want this for your business?" section. The contact must be **one-tap, not plain text** — a clickable WhatsApp link (`https://wa.me/60127972969`) and a `mailto:` link (`mailto:me@hoelee.com?subject=...`), plus `hoelee.com` — and it should **name the concrete service offered** (e.g. "I build Telegram support bots like this one", "I set up self-hosted monitoring pipelines"), so a reader can tap straight through and ask for that specific thing. This is a business blog — every flagship post doubles as a lead magnet.
|
||||
6. **Case studies open with a "why it matters" section** (the business benefit: saves money, 24/7, converts browsers, remembers customers) before the technical architecture.
|
||||
|
||||
**Verify after build:** the English page links to `/posts/zh/<slug>/` and the Chinese page links back to `/posts/<slug>/` (the auto language-switch).
|
||||
@@ -4,9 +4,11 @@ Living list of what's done and what's next for blog.hoelee.com. Work through the
|
||||
|
||||
**Status key:** ✅ done · 🔵 in progress · ⬜ not started
|
||||
|
||||
> **How to resume the project:** start at the top-most ⬜ item in the **Execution Plan** (§2) below and work downward. Each step is self-contained, has a "done when" criterion, and references the governing doc. Don't jump ahead — earlier steps unlock later ones.
|
||||
|
||||
---
|
||||
|
||||
## Done (foundation)
|
||||
## 1. Done (foundation)
|
||||
|
||||
- ✅ Astro 5 static + Markdown, Gitea Actions CI/CD → nginx → Cloudflare (deployed)
|
||||
- ✅ Design system: hoelee.com brand palette, Inter + JetBrains Mono, light/dark, sticky nav, code copy button
|
||||
@@ -14,47 +16,150 @@ Living list of what's done and what's next for blog.hoelee.com. Work through the
|
||||
- ✅ Author card + `Person`/`ProfilePage` JSON-LD (E-E-A-T), article meta, reading time, related posts
|
||||
- ✅ Full SEO: canonical, Open Graph (+dims), twitter:card, favicon (all sizes), RSS + sitemap
|
||||
- ✅ Category pages (`/categories/`, `/categories/[category]/`)
|
||||
- ✅ Locale scheme: English flat in `posts/`, Chinese in `posts/zh/` (lang derived from folder, no `lang:` frontmatter)
|
||||
- ✅ Language switcher in nav (EN ↔ 中文) + `/zh/` landing page
|
||||
- ✅ Dark mode default (light is opt-in via toggle)
|
||||
- ✅ Case study post "how-i-host-this-blog" updated with docker-compose sample + real installation gotchas (from commit history)
|
||||
- ✅ Knowledge guides in `docs/` (content, design, seo, ops) + README index + `hoelee-blog` skill
|
||||
- ⬜ **Make the Gitea repo public** — verified no secrets in source or history (PAT is a `${{ secrets.PAT }}` reference, not hardcoded). Recommended: yes, public — it's a portfolio artifact. Action: flip visibility in Gitea repo settings.
|
||||
- ✅ Locale scheme: English flat in `posts/`, Chinese in `posts/zh/` (lang derived from folder)
|
||||
- ✅ Language switcher in nav — links to the **same post** in the other language (auto-matches by `zh/` prefix)
|
||||
- ✅ Locale-aware nav labels (EN/ZH)
|
||||
- ✅ Dark mode default (light opt-in via toggle)
|
||||
- ✅ Case study "How I Host This Blog"
|
||||
- ✅ Chinese translation of the case study (`posts/zh/how-i-host-this-blog.md`)
|
||||
- ✅ hello-world intro post
|
||||
- ✅ Case study "How I Built the DigiKedai Telegram AI Bot" + Chinese twin (`posts/zh/how-i-built-the-digikedai-telegram-bot/`)
|
||||
- ✅ Business framing corrected (website design & development = primary; email hosting = secondary)
|
||||
- ✅ Knowledge guides in `docs/` + README index + `hoelee-blog` skill
|
||||
- ✅ Both repos public (Gitea + GitHub) with title/description/homepage/topics + `v1.0.0` release
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — Content (80% of value; do this first)
|
||||
## 2. Execution Plan (work top → bottom, one step at a time)
|
||||
|
||||
- ⬜ **Write the 2 flagship case studies** — highest ROI, these are the portfolio:
|
||||
- ⬜ "How I built the DigiKedai Telegram AI bot" (DSM Docker + Cloudflare tunnel webhook + LiteLLM)
|
||||
- ⬜ "Self-hosting a mem0 memory stack" (API + LiteLLM + pgvector)
|
||||
- ⬜ **2–3 gotcha posts** from real debugging history (short, Google-friendly):
|
||||
- ⬜ "The Traefik forward-auth gotcha that cost me a day"
|
||||
- ⬜ "Site-to-site OpenVPN behind CGNAT"
|
||||
- ⬜ "Fixing the WordPress /cv 301→404 chain" (from own audit)
|
||||
- ⬜ **"Hard job → post" habit** — every solved problem becomes a `notes` entry the same week (template in `docs/content-guide.md` §4)
|
||||
> This plan comes from a full research pass (Sept 2026) comparing blog.hoelee.com against reference developer blogs (Simon Willison, Josh Comeau, Dan Abramov/overreacted, Julia Evans) + industry surveys. Priority is fixed: **identity & content → discovery → polish.** Don't reorder unless the user says so.
|
||||
|
||||
### Phase A — Identity (highest ROI, ~2–3 hrs total)
|
||||
|
||||
**Step A1 — Add a real author photo (headshot).**
|
||||
The #1 gap vs. every reference blog: the avatar is a letter "M" placeholder and there's no photo anywhere on the site. Every credible personal dev blog has a human face.
|
||||
- [ ] User provides one headshot (square, ≥800×800 for avatar; also source for og).
|
||||
- [ ] Replace `avatar` letter with the photo in: nav/brand (optional), author card (About + every post), `ProfilePage` JSON-LD `image`.
|
||||
- [ ] Add the photo to `og-default.png` template so the default share card has a face.
|
||||
- **Governing doc:** `design-guide.md` §2 (author box), §5 (E-E-A-T name+photo consistency).
|
||||
- **Done when:** a real face renders in the author card on About + every post; `curl` shows no 404 for the asset.
|
||||
|
||||
**Step A2 — Align the homepage title & hero framing.**
|
||||
The `<title>` says "engineering, DevOps & self-hosting" but the hero says "full-stack developer and DevOps engineer" — two slightly different framings.
|
||||
- [ ] Pick one line (recommend: "full-stack developer & DevOps engineer") and use it in both `<title>`/meta description and hero paragraph.
|
||||
- **Done when:** homepage title, meta description, and hero all say the same thing about who Hoelee is.
|
||||
|
||||
**Step A3 — Add a "Start here" / featured posts route.**
|
||||
New visitors land on reverse-chronological "Latest posts" with no guidance to the best content (Julia Evans' Favorites, Josh Comeau's featured posts both solve this).
|
||||
- [ ] Add a "Start here" (or "Featured") section on the homepage surfacing 2–3 flagship case studies.
|
||||
- [ ] Optionally add a `/favorites` or `/start-here` page (defer the dedicated page until ≥6 strong posts; the homepage strip is the immediate win).
|
||||
- **Done when:** homepage shows a featured/start-here strip above or beside "Latest posts".
|
||||
|
||||
### Phase B — Content (80% of value; the long game)
|
||||
|
||||
**Step B1 — Write the 2nd flagship case study: "Self-Hosting a Mem0 Memory Stack".** ✅ Done 2026-09-16
|
||||
The Mem0 flagship is already the single highest-value unwritten post in the backlog.
|
||||
- [x] Write `src/content/posts/self-hosting-mem0.md` (category `case-studies`).
|
||||
- [x] Write Chinese twin `src/content/posts/zh/self-hosting-mem0.md` (same filename → auto language-switch).
|
||||
- [x] Follow the "hard job → post" template (§4 content-guide) + open with "why it matters" + end with hire CTA (§8 post-guideline).
|
||||
- **Governing doc:** `content-guide.md` §4/§8, `post-guideline.md` §8.
|
||||
- **Done when:** ✅ both EN + ZH pages live, language-switch works, hire CTA present.
|
||||
|
||||
**Step B1b — Write the self-hosted STT case study.** ✅ Done 2026-09-19
|
||||
`self-hosted-speech-to-text-api.md` (EN + ZH): whisper.cpp on GPU + n8n auth gate +
|
||||
nginx gateway, with the four build traps and the "5x faster than typing" business case.
|
||||
- [x] EN + ZH posts, custom OG + banner, hire CTA.
|
||||
- **Done when:** ✅ both pages build, language-switch verified, images generated.
|
||||
|
||||
**Step B2 — Write 2–3 short "gotcha" posts (Google-friendly, compound over time).**
|
||||
- [x] "Replacing RDPGuard With IPBan: The Traps Nobody Documents" (EN + ZH, `devops`, 2026-09-19) — the uninstaller that unbans 12 attackers, `--install-service` doesn't exist in v4.1.0, `ExpireTime` vs `BanTime`. Both images custom.
|
||||
- [x] "When Your Database Client Lies to You: Patching Workbench 26 for MariaDB" (EN + ZH, `devops`, 2026-09-19) — a client whose error handler crashed while reporting its own errors, masking every real failure; three patches to Oracle's bundled code, all stemming from `major >= 8` being an invalid MySQL-vs-MariaDB test. Both images custom.
|
||||
- [ ] "The Traefik forward-auth gotcha that cost me a day"
|
||||
- [ ] "Site-to-site OpenVPN behind CGNAT"
|
||||
- [ ] "Fixing the WordPress /cv 301→404 chain" (from own audit)
|
||||
- **Governing doc:** `content-guide.md` §3 (post type #3), `post-guideline.md`.
|
||||
- **Done when:** ≥2 gotcha posts live (these are `notes`/`devops`, no Chinese translation required per §8).
|
||||
- ⚠ **Note:** `post-guideline.md` §8 (newer) says *every* post gets a ZH twin — the "no Chinese required" note above is stale. The RDPGuard post was published EN + ZH.
|
||||
|
||||
**Step B2b — Draft bank (written, held as `draft: true`, publish when content runs short).** ✅ Drafted 2026-09-20
|
||||
Two finished posts (EN + ZH, each with frontmatter pointing at OG + banner paths) sitting in the repo but
|
||||
**not built or listed** — `draft: true` excludes them from all listings and generates no pages.
|
||||
|
||||
| Slug | Category | Status | Assets |
|
||||
|---|---|---|---|
|
||||
| `migrating-codeigniter-iis-to-openlitespeed` | `engineering` | drafted, unpublished | OG + banner PNGs **not yet generated** |
|
||||
| `upgrading-codeigniter-46-to-47` | `notes` | drafted, unpublished | OG + banner PNGs **not yet generated** |
|
||||
|
||||
**To publish one later:**
|
||||
1. Flip `draft: true` → `draft: false` in **both** `src/content/posts/<slug>.md` and `src/content/posts/zh/<slug>.md`.
|
||||
2. Set the real `pubDate` (currently `2026-09-20`, the draft date) in both files.
|
||||
3. Generate its images: `node scripts/og-gen/generate.mjs <slug>` and `node scripts/banner-gen/generate.mjs <slug>` (add a `TERMINALS[slug]` / `BANNERS[slug]` entry first for the custom panel).
|
||||
4. `npm run build`, commit, `git push origin main`.
|
||||
5. Verify both URLs return 200 and the language switcher links them.
|
||||
|
||||
- **Why these two:** `engineering` had only 1 post and `tutorials` only 1 — the blog was ~all `devops`/`case-studies`. These put PHP/CodeIgniter (the actual day-job stack) on the blog, which is what a PHP full-stack recruiter searches for.
|
||||
- **Governing doc:** `content-guide.md` §3/§4, `post-guideline.md` §8.
|
||||
- **Done when:** both are published live with EN+ZH, custom OG + banner, and verified 200.
|
||||
|
||||
**Step B3 — Adopt the "hard job → post" habit.**
|
||||
Every solved problem becomes a `notes` entry the same week.
|
||||
- [ ] Revisit cadence target: 2 posts/month → 1/week (`content-guide.md` §5).
|
||||
- **Done when:** 3 consecutive months hit the 2-posts/month floor.
|
||||
|
||||
### Phase C — Discovery & structure (Tier 2)
|
||||
|
||||
**Step C1 — Per-post custom OG images (at least for case studies).**
|
||||
Currently every post shares the generic 14KB `og-default.png` — flagship posts share the same bland card as category pages.
|
||||
- [ ] Build a branded 1200×630 OG template (name + face + title).
|
||||
- [ ] Generate a custom `ogImage` for each case study (frontmatter `ogImage:` field already supported).
|
||||
- **Governing doc:** `design-guide.md` §2/§3, `content-guide.md` §6 (ogImage field).
|
||||
- **Done when:** each case study's `og:image` is unique and 1200×630.
|
||||
|
||||
**Step C2 — Tag pages** (`/tags/[tag]/` archive pages for fine-grained discovery + internal linking).
|
||||
- [ ] Add tag archive routes (tags currently render as labels only).
|
||||
- **Done when:** clicking a tag on any post opens a working `/tags/<tag>/` page.
|
||||
|
||||
**Step C3 — Categories page shows all 7 categories** (not just those with posts), with "0 posts / coming soon" for empty ones — signals intended coverage. ✅ Done 2026-09-13
|
||||
- [x] `/categories/` lists all 7 categories with name, description, per-category terminal-style SVG illustration (CategoryArt/Grid components) and a post count; empty ones show "0 posts · coming soon" as a non-link.
|
||||
- **Done when:** ✅ all 7 render with a placeholder for empty ones + descriptions + illustrations.
|
||||
|
||||
**Step C4 — Dedicated `/zh/posts/` and `/zh/categories/` archive pages.** 🔵 In progress
|
||||
- [x] `/zh/categories/` index + `/zh/categories/[category]/` detail pages live (zh nav "分类" points there; PostList is locale-aware with zh-CN dates).
|
||||
- [ ] `/zh/posts/` archive still missing — zh nav "文章" falls back to `/zh/` landing (zh post count already 17, the archive is due).
|
||||
- **Done when:** zh nav links to real `/zh/posts/` + `/zh/categories/` archives.
|
||||
|
||||
### Phase D — Polish / later (Tier 3)
|
||||
|
||||
**Step D1 — Search** (AstroPaper-style fuzzy search). Low priority until >20 posts.
|
||||
**Step D2 — Google Search Console submission** — submit `sitemap-index.xml` for faster indexing.
|
||||
**Step D3 — Newsletter / email capture** — only after real traffic exists (agree: do NOT add yet).
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — Structural gaps
|
||||
## 3. Research Findings Snapshot (Sept 2026)
|
||||
|
||||
- ⬜ **Tag pages** — tags currently render as labels only; add `/tags/[tag]/` archive pages for fine-grained discovery + internal linking
|
||||
- ⬜ **Categories page shows all 7 categories** (not just those with posts) — signal intended coverage; show "0 posts / coming soon" for empty ones
|
||||
- ⬜ **Search** — AstroPaper-style fuzzy search (low priority until >20 posts)
|
||||
What the reference blogs do that blog.hoelee.com should mirror, ranked:
|
||||
|
||||
| Finding | Reference example | Status on blog.hoelee.com |
|
||||
|---|---|---|
|
||||
| Real name + photo + one-line identity | All four | ⚠️ name ✅, photo ❌ (letter "M") — **Step A1** |
|
||||
| Focused thesis (one sentence on what it's about) | Julia Evans, Simon Willison | ⚠️ has it, but title/hero drift — **Step A2** |
|
||||
| Honesty about what you *don't* know | Simon, Dan Abramov | ✅ strong (DigiKedai "bugs that ate an afternoon") |
|
||||
| Specific detail: code, diagrams, numbers, bug stories | All four | ✅ strong |
|
||||
| Consistent cadence (slow is fine, dead is not) | Julia (~monthly), Simon (daily) | ⚠️ only 3 posts, all Sept 4–6 — **Phase B** |
|
||||
| "Start here" / Favorites route | Julia Evans, Josh Comeau | ❌ — **Step A3** |
|
||||
| RSS + sitemap + clean SEO | All four | ✅ |
|
||||
| Per-post OG images | Josh Comeau | ❌ — **Step C1** |
|
||||
| Search (once >15–20 posts) | Josh Comeau | ❌ deferred — **Step D1** |
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Polish / later
|
||||
|
||||
- ⬜ **Verify Chinese content split** — confirm `zh/` posts don't appear in EN feed (the `isEn` helper already filters; re-check when first zh post lands)
|
||||
- ⬜ **Google Search Console submission** — submit `sitemap-index.xml` for faster indexing
|
||||
- ⬜ **Newsletter / email capture** — only after real traffic exists
|
||||
|
||||
---
|
||||
|
||||
## Conventions (non-negotiable)
|
||||
## 4. Conventions (non-negotiable)
|
||||
|
||||
- Push git.hoelee.com first, then GitHub
|
||||
- English-first; Chinese selective (2–3 flagship case studies); no Malay
|
||||
- No overclaiming, especially Web3
|
||||
- Name identity: "Lee Teong Hoe" / "Mr Hoelee" + same photo + same `sameAs` handles everywhere
|
||||
- Business framing: website design & development is primary; email hosting is secondary
|
||||
- English post titles use Title Case
|
||||
- See `docs/post-guideline.md` for post-writing rules; `docs/content-guide.md` for strategy; `docs/design-guide.md` for UI
|
||||
|
||||
@@ -45,6 +45,14 @@ The entire blog strategy is a **name-search play**, so identity must be airtight
|
||||
- [ ] Related-posts module (3 cards) on every post
|
||||
- [ ] No orphaned/thin pages (avoid the hoelee.com ~400-attachment mistake)
|
||||
|
||||
### Crawler / sitemap mechanics (as built)
|
||||
|
||||
- `robots.txt` is a **build-time endpoint** (`src/pages/robots.txt.ts`), not a static file in `public/`. It interpolates `SITE.url` so the `Sitemap:` line can't drift. **Keep it pure ASCII** — the response has no charset, so a non-ASCII byte renders as mojibake (`鈥�`) in viewers that fall back to a legacy codepage. Check: `LC_ALL=C grep -c '[^ -~]' dist/robots.txt` must be `0`.
|
||||
- Policy is **allow-all**, including AI *training* crawlers. Rationale: `robots.txt` is advisory and unenforced (Cloudflare documents it as voluntarily honoured), so a blocklist there buys nothing; enforcement, if ever wanted, belongs in Cloudflare AI Crawl Control. `/pagefind/` is the only exclusion (build artifacts, not content).
|
||||
- **Why a real robots.txt matters at all:** writing one turned Cloudflare's *Content Signals Policy* placeholder from a **replacement** into a **prepend**. On a Free-plan zone with no origin `robots.txt`, Cloudflare serves that placeholder *in place of* your file — a comment block with no `User-agent`, no directives and no `Sitemap:` line. Confirm the origin is serving yours before blaming Astro.
|
||||
- Sitemap `lastmod` + `xhtml:link` hreflang alternates are **derived**, never authored — see the rule in the project skill. `sitemap-index.xml` legitimately contains exactly one entry (`sitemap-0.xml`) and looks empty in a viewer; the URLs are in `sitemap-0.xml` (77 of them at 29 posts). Don't "fix" the index.
|
||||
- hreflang exists in **two places on purpose**: in-page `<link rel="alternate">` (from `BaseLayout`'s `altLocaleUrl`) and sitemap `xhtml:link`. Google treats the sitemap as more authoritative when they disagree, so both must be kept in sync — which is automatic for posts and for any page that passes `altLocaleUrl`.
|
||||
|
||||
---
|
||||
|
||||
## Syndication policy
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"@astrojs/sitemap": "^3.7.4",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"astro": "^5.5.0"
|
||||
"astro": "^5.5.0",
|
||||
"pagefind": "^1.5.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/compiler": {
|
||||
@@ -1184,6 +1185,97 @@
|
||||
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pagefind/darwin-arm64": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz",
|
||||
"integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@pagefind/darwin-x64": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz",
|
||||
"integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@pagefind/freebsd-x64": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz",
|
||||
"integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@pagefind/linux-arm64": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz",
|
||||
"integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@pagefind/linux-x64": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz",
|
||||
"integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@pagefind/windows-arm64": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz",
|
||||
"integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@pagefind/windows-x64": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.5.2.tgz",
|
||||
"integrity": "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
|
||||
@@ -4028,6 +4120,24 @@
|
||||
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pagefind": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz",
|
||||
"integrity": "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"pagefind": "lib/runner/bin.cjs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@pagefind/darwin-arm64": "1.5.2",
|
||||
"@pagefind/darwin-x64": "1.5.2",
|
||||
"@pagefind/freebsd-x64": "1.5.2",
|
||||
"@pagefind/linux-arm64": "1.5.2",
|
||||
"@pagefind/linux-x64": "1.5.2",
|
||||
"@pagefind/windows-arm64": "1.5.2",
|
||||
"@pagefind/windows-x64": "1.5.2"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-latin": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"build": "astro build && pagefind --site dist",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
@@ -14,6 +14,7 @@
|
||||
"@astrojs/sitemap": "^3.7.4",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"astro": "^5.5.0"
|
||||
"astro": "^5.5.0",
|
||||
"pagefind": "^1.5.2"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 271 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 180 B After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,43 @@
|
||||
# banner-gen — per-post banner/hero image generator
|
||||
|
||||
Generates a branded 1600×900 (16:9) banner image for a blog post — shown at
|
||||
the top of the article, above the title. Matches the site's cobalt-blue
|
||||
branding and the "terminal / debugging" visual language (siblings with
|
||||
`scripts/og-gen/`, which produces the 1200×630 social-share image).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
node scripts/banner-gen/generate.mjs <post-slug>
|
||||
```
|
||||
|
||||
Reads the post's category from frontmatter, fills `template.html`, renders
|
||||
with headless Chrome, and writes `public/banners/<slug>.png`.
|
||||
|
||||
Then add `banner: /banners/<slug>.png` to the post frontmatter (en + zh) —
|
||||
`[...slug].astro` renders it above the H1 title.
|
||||
|
||||
## Customizing content
|
||||
|
||||
Each post's banner content lives in `BANNERS` in `generate.mjs`, keyed by
|
||||
slug, with three parts:
|
||||
|
||||
- `titlebar` — the terminal window's title-bar text
|
||||
- `lines` — terminal body rows: `{ t, text }` where `t` ∈
|
||||
`cmd` · `dim` · `err` (red) · `ok` (green) · `hl` (amber) · `prompt` (`$`)
|
||||
- `flow` — bottom pipeline steps: `{ n: '1', label: '...', err?: true }`
|
||||
|
||||
No entry → a generic default banner.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Google Chrome at `C:\Program Files\Google\Chrome\Application\chrome.exe`
|
||||
(`CHROME_PATH` to override).
|
||||
- Node 20+.
|
||||
|
||||
## Relationship to og-gen
|
||||
|
||||
- og-gen → `public/og/<slug>.png` (1200×630, 2:1) for social sharing.
|
||||
- banner-gen → `public/banners/<slug>.png` (1600×900, 16:9) for the article
|
||||
hero. The banner is a "filled" visual (terminal + flow pipeline), not a
|
||||
poster — avoid large empty margins.
|
||||
@@ -0,0 +1,685 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* banner-gen — generate a 1600×900 (16:9) banner/hero image for a blog post.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/banner-gen/generate.mjs <slug> [--all]
|
||||
*
|
||||
* Reads the post's frontmatter, fills the template, renders with headless
|
||||
* Chrome, and writes public/banners/<slug>.png.
|
||||
*
|
||||
* Each post's content (titlebar, terminal lines, flow steps) is defined in
|
||||
* BANNERS below, keyed by slug. Falls back to a generic default.
|
||||
*
|
||||
* The post frontmatter then needs `banner: /banners/<slug>.png` so the
|
||||
* article renders it above the title ([...slug].astro does this).
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..');
|
||||
|
||||
const slug = process.argv[2];
|
||||
if (!slug) {
|
||||
console.error('Usage: node scripts/banner-gen/generate.mjs <slug> | --all');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------- per-post banner content ----------
|
||||
// lines: [{ t: 'cmd'|'dim'|'err'|'ok'|'hl'|'prompt', text }]
|
||||
// flow: [{ n: '1', label: '...', err?: true }]
|
||||
const BANNERS = {
|
||||
'why-telegram-bot-notifications-die': {
|
||||
titlebar: 'root@dsm — carousell-monitor',
|
||||
lines: [
|
||||
{ t: 'dim', text: '2026-09-08 20:45:41' }, { t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'scraping "uniform" — 49 cards parsed' },
|
||||
{ t: 'dim', text: '2026-09-08 20:45:42' }, { t: 'prompt', text: 'INFO' }, { t: 'cmd', text: '2 new listings → archiving to NocoDB' },
|
||||
{ t: 'dim', text: '2026-09-08 20:45:44' }, { t: 'prompt', text: 'WARN' }, { t: 'err', text: 'telegram sendPhoto failed: 400 nginx/1.30.1' },
|
||||
{ t: 'dim', text: '2026-09-08 20:45:44' }, { t: 'prompt', text: 'WARN' }, { t: 'err', text: 'telegram sendMessage failed: 400 nginx/1.30.1' },
|
||||
{ t: 'prompt', text: '$', },
|
||||
{ t: 'cmd', text: 'getent hosts api.telegram.org' },
|
||||
{ t: 'hl', text: '2001:67c:4e8:f004::9' }, { t: 'dim', text: 'api.telegram.org ← IPv6 only, no A record' },
|
||||
{ t: 'prompt', text: '$' },
|
||||
{ t: 'cmd', text: 'fix = extra_hosts → IPv4 · multipart join → binary-safe' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ delivered ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'Collect listings' },
|
||||
{ n: '2', label: 'sendPhoto 400', err: true },
|
||||
{ n: '3', label: 'DNS → IPv6 only' },
|
||||
{ n: '4', label: 'multipart fix' },
|
||||
{ n: '5', label: 'delivered ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'authentik-major-upgrade-gotchas': {
|
||||
titlebar: 'root@dsm — authentik upgrade',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker compose pull authentik-worker' },
|
||||
{ t: 'dim', text: 'Pulling authentik:2026.8.1 ... done' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker compose up -d && docker logs -f authentik' },
|
||||
{ t: 'err', text: 'authorization_flow not found · SSO broken' },
|
||||
{ t: 'dim', text: 'trusted_proxy_cidrs was reset · storage mount changed' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = authorization_flow → authentication_flow' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ SSO restored ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: '2025.8 → 2026.8' },
|
||||
{ n: '2', label: 'SSO broken', err: true },
|
||||
{ n: '3', label: 'flow renamed' },
|
||||
{ n: '4', label: 'trusted proxy' },
|
||||
{ n: '5', label: 'restored ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'using-chinese-llm-apis-from-malaysia': {
|
||||
titlebar: 'root@dsm — llm cost watch',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'deepseek-v4-flash · off-peak' },
|
||||
{ t: 'dim', text: '$0.15 / 1M in · $0.60 / 1M out — cached input ~20× less' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'tokenrhythm ¥68 → GLM-5.3 · Qwen3.8-Max · Kimi K2.7-Code' },
|
||||
{ t: 'err', text: 'wall: RMB payment · mainland CN phone verification' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = Alipay top-up · email-signup platforms' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ ~US$10 credit ≈ a month of agent work ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'RMB via Alipay' },
|
||||
{ n: '2', label: 'CN phone wall', err: true },
|
||||
{ n: '3', label: 'OpenAI-compatible key' },
|
||||
{ n: '4', label: '¥68 credit' },
|
||||
{ n: '5', label: 'agents run ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'how-i-host-this-blog': {
|
||||
titlebar: 'root@unraid — deploy pipeline',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git push origin main' },
|
||||
{ t: 'dim', text: '→ git.hoelee.com/hoelee/hoelee-blog' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'Gitea Actions → build (Astro 5)' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'npm ci && npm run build → dist/' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'deploy → unRaid runner → nginx' },
|
||||
{ t: 'ok', text: '✓ served via Cloudflare (cache + SSL)' },
|
||||
{ t: 'dim', text: 'git-as-CMS · zero-downtime deploy' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'git push' },
|
||||
{ n: '2', label: 'Gitea Actions' },
|
||||
{ n: '3', label: 'Astro build' },
|
||||
{ n: '4', label: 'unRaid nginx' },
|
||||
{ n: '5', label: 'Cloudflare' },
|
||||
],
|
||||
},
|
||||
|
||||
'how-i-built-the-digikedai-telegram-bot': {
|
||||
titlebar: 'DigiKedai — AI support bot',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '>' }, { t: 'cmd', text: 'user: "does this course have a free trial?"' },
|
||||
{ t: 'prompt', text: '¶' }, { t: 'ok', text: 'bot: yes — here\'s your trial account ✓' },
|
||||
{ t: 'dim', text: '(answered in < 2s, no human in the loop)' },
|
||||
{ t: 'prompt', text: '>' }, { t: 'cmd', text: 'user: "which package should I buy?"' },
|
||||
{ t: 'prompt', text: '¶' }, { t: 'ok', text: 'bot: recommends + provisions a free account' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'stack = grammY · LiteLLM · Cloudflare tunnel · 24/7' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'User asks' },
|
||||
{ n: '2', label: 'grammY webhook' },
|
||||
{ n: '3', label: 'LiteLLM' },
|
||||
{ n: '4', label: 'AI answers' },
|
||||
{ n: '5', label: 'provisions ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'when-smart-says-healthy-but-your-raid-is-corrupting-data': {
|
||||
titlebar: 'root@unraid — cache pool scrub',
|
||||
lines: [
|
||||
{ t: 'dim', text: 'btrfs RAID1 · 2× Samsung PM9A3 NVMe · same batch' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'btrfs scrub /mnt/cache' },
|
||||
{ t: 'err', text: 'csum 0x8941f998 recurring = CRC32C(zero block)' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'self-heal rewrite → does NOT stick' },
|
||||
{ t: 'err', text: 'write-path corruption · SMART stays clean' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = restore VM + replace both drives' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'SMART clean' },
|
||||
{ n: '2', label: 'csum zeros', err: true },
|
||||
{ n: '3', label: 'self-heal no-stick' },
|
||||
{ n: '4', label: 'restore VM' },
|
||||
{ n: '5', label: 'replace both ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'self-hosting-mem0-memory-stack': {
|
||||
titlebar: 'root@dsm — mem0 memory stack',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: "curl -X POST :20015/memories -H X-Api-Key" },
|
||||
{ t: 'dim', text: 'user_id + text → mem0 extracts & stores' },
|
||||
{ t: 'prompt', text: '¶' }, { t: 'ok', text: '→ remembers across sessions ✓' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'stack = mem0-api + LiteLLM + pgvector(pg17)' },
|
||||
{ t: 'err', text: 'infer=true → LLM hop · slow writes' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: 'self-hosted · data stays on LAN ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'POST /memories' },
|
||||
{ n: '2', label: 'store fact' },
|
||||
{ n: '3', label: 'search' },
|
||||
{ n: '4', label: 'feed context' },
|
||||
{ n: '5', label: 'remembers ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'hardening-a-tor-onion-service': {
|
||||
titlebar: 'root@tor-host — onion service audit',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'wget -qO- ipv4.icanhazip.com # from inside the app' },
|
||||
{ t: 'err', text: '→ home IP returned · iptables block silently gone' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'cap_add: NET_BIND_SERVICE · keep :80' },
|
||||
{ t: 'err', text: 'listen tcp :80: bind: permission denied · CapEff=0' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'networks: internal:true · port 8080 · SocksPort 0' },
|
||||
{ t: 'ok', text: '→ bad address ✓ · zero egress ✓ · all containers healthy ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'Audit egress', err: true },
|
||||
{ n: '2', label: 'internal:true' },
|
||||
{ n: '3', label: 'non-root :8080' },
|
||||
{ n: '4', label: 'healthchecks' },
|
||||
{ n: '5', label: 'verify ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'hello-world': {
|
||||
titlebar: '~/hoelee-blog — first commit',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git init hoelee-blog && git add -A' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git commit -m "Hello, world"' },
|
||||
{ t: 'ok', text: '[main 0000001] Hello, world ✓' },
|
||||
{ t: 'dim', text: 'technical writing · self-hosting · build log' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'about → blog.hoelee.com' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'Why this blog' },
|
||||
{ n: '2', label: 'what I build' },
|
||||
{ n: '3', label: 'self-hosting' },
|
||||
{ n: '4', label: 'learn in public' },
|
||||
],
|
||||
},
|
||||
|
||||
'why-your-headless-browser-cant-scrape-everything': {
|
||||
titlebar: 'root@dsm — headless browser vs anti-bot',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'browserless → goofish.com search?q=iPhone15' },
|
||||
{ t: 'err', text: '非法访问 · "please use a normal browser"' },
|
||||
{ t: 'dim', text: 'stealth flag · patched navigator.webdriver · real UA → still blocked' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl carousell search → parse __PRELOADED_STATE__' },
|
||||
{ t: 'ok', text: 'listings + price + photos ✓ (no browser, no stealth)' },
|
||||
{ t: 'hl', text: 'server-rendered JSON ≠ signed async API' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: 'read the data path before choosing a tool ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'goofish' },
|
||||
{ n: '2', label: 'signed API', err: true },
|
||||
{ n: '3', label: 'carousell' },
|
||||
{ n: '4', label: 'server JSON', err: false },
|
||||
{ n: '5', label: 'parsed ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'the-nocodb-attachment-that-wouldnt-update': {
|
||||
titlebar: 'root@dsm — nocodb backfill',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'PATCH image path = fullsize_url' },
|
||||
{ t: 'dim', text: '200 OK — but readback still shows the old thumbnail' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'grep image.json | jq .id' },
|
||||
{ t: 'hl', text: 'id present → NocoDB resolves by id, ignores new path' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'drop id + signedPath · url.replace("_progressive_thumbnail","")' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '300 rows updated ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'patch path' },
|
||||
{ n: '2', label: 'id keeps old', err: true },
|
||||
{ n: '3', label: 'strip id' },
|
||||
{ n: '4', label: 'replace() suffix' },
|
||||
{ n: '5', label: 'updated ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'how-to-verify-a-hosting-provider-before-you-buy': {
|
||||
titlebar: '~/hosting-due-diligence',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'whois vps.tld | grep -i created' },
|
||||
{ t: 'err', text: 'registration: 2026-05 — homepage says \"since 2012\"' },
|
||||
{ t: 'dim', text: 'a \"since\" claim on a young domain is never verifiable' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl AUP | grep -iE \"tor|reverse proxy|tunnel\"' },
|
||||
{ t: 'hl', text: 'found: \"TOR nodes\", \"anonymizing services\" → hard no' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'trustpilot trend · r/hosting · retention clause' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ verdict ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'domain age' },
|
||||
{ n: '2', label: '\"since\" claim', err: true },
|
||||
{ n: '3', label: 'read AUP' },
|
||||
{ n: '4', label: 'reputation' },
|
||||
{ n: '5', label: 'verdict ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'how-i-vetted-20-vps-providers-with-parallel-subagents': {
|
||||
titlebar: '~/vendor-due-diligence — fan-out',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'delegate → 3 subagents, 20 providers' },
|
||||
{ t: 'dim', text: 'batch A: 5 · batch B: 6 · batch C: 9 (parallel)' },
|
||||
{ t: 'info', text: 'each → WHOIS · AUP · privacy · pricing · reviews' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'merge → one scorecard' },
|
||||
{ t: 'hl', text: 'aup flags: "TOR nodes" · domain-age mismatch · metered cap' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ ~20 providers audited in 3h ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'checklist' },
|
||||
{ n: '2', label: '3 subagents' },
|
||||
{ n: '3', label: 'parallel fetch' },
|
||||
{ n: '4', label: 'scorecard' },
|
||||
{ n: '5', label: 'verdict ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'automating-cyberpanel-without-the-ui': {
|
||||
titlebar: 'root@cyberpanel — reverse-engineering the v2 API',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl -X POST .../api/verifyConnection' },
|
||||
{ t: 'err', text: '404 — the /api/ prefix was dropped in v2' },
|
||||
{ t: 'dim', text: 'docs say adminUser/adminPass · panel says "This request need session."' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'GET / → csrftoken → POST /verifyLogin (X-CSRFToken)' },
|
||||
{ t: 'hl', text: 'loginStatus: 1 · session cookie set' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'POST /websites/fetchWebsitesList' },
|
||||
{ t: 'ok', text: '→ all sites + SSL expiry ✓ (no UI)' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'docs 404', err: true },
|
||||
{ n: '2', label: 'session wall' },
|
||||
{ n: '3', label: 'CSRF token' },
|
||||
{ n: '4', label: 'verifyLogin' },
|
||||
{ n: '5', label: 'sites ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'syncing-a-self-improving-ai-agent-across-machines': {
|
||||
titlebar: '~/hermes — sync agent across machines',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'git pull --ff-only origin main' },
|
||||
{ t: 'err', text: 'refusing: local divergence · never --force' },
|
||||
{ t: 'dim', text: 'distribution repo skills/ is a COPY, not the live dir' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'diff -rq live ~/skills repo copy' },
|
||||
{ t: 'hl', text: 'Only in live/: hermes-profile-sync ← created after last push' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'stage real skills only · commit · push · verify' },
|
||||
{ t: 'ok', text: '→ edbf7ac on both ends ✓ · 172 skills · 49MB junk stripped' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'pull --ff-only' },
|
||||
{ n: '2', label: 'diff 3-way', err: true },
|
||||
{ n: '3', label: 'merge / ask' },
|
||||
{ n: '4', label: 'push' },
|
||||
{ n: '5', label: 'verify ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'ai-furniture-compositing-with-flux-kontext': {
|
||||
titlebar: 'client — furniture compositing PoC',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'POST fal-ai/flux-pro/kontext/multi · 2 photos in' },
|
||||
{ t: 'err', text: 'toDataURL: tainted canvas · may not be exported' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = same-origin paths · enhance_prompt:false · name objects' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 1 staged room out ✓ ($0.04, ~17s)' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: '2 photos in' },
|
||||
{ n: '2', label: 'tainted canvas', err: true },
|
||||
{ n: '3', label: 'same-origin fix' },
|
||||
{ n: '4', label: 'prompt anchoring' },
|
||||
{ n: '5', label: '1 room out ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'shipping-an-ai-photo-editor-as-a-wordpress-plugin': {
|
||||
titlebar: 'wp-admin — AI Remix Photo plugin',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'wp plugin list · hre-ai-remix 0.0.1 → 0.0.22' },
|
||||
{ t: 'err', text: '404 …/v1admin/photos — rest_url() has no trailing slash' },
|
||||
{ t: 'dim', text: 'routes in is_admin() ✗ · watermark silent-fallback ✗' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: "join REST with '/admin/…' · log root cause · warn admin" },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 22 releases · 58 commits · 4 were the AI ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'PoC → plugin' },
|
||||
{ n: '2', label: 'v1admin 404', err: true },
|
||||
{ n: '3', label: 'silent watermark', err: true },
|
||||
{ n: '4', label: 'server-side gates' },
|
||||
{ n: '5', label: '0.0.22 ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'best-ai-video-generators-2026': {
|
||||
titlebar: '~/blog — AI video free vs paid 2026',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'compare cloud APIs vs open weights' },
|
||||
{ t: 'err', text: '"free" = 4 different deals · credits ≠ seconds' },
|
||||
{ t: 'dim', text: 'Sora 2 app gone 04-26 · API sunset 09-24' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'LTX-2.3 via PinkCherry · demo clip ↓' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 11 tools · pricing checked 2026-09-11 ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'free credits' },
|
||||
{ n: '2', label: 'cloud APIs' },
|
||||
{ n: '3', label: 'open weights' },
|
||||
{ n: '4', label: 'LTX-2.3 test' },
|
||||
{ n: '5', label: 'pick one ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'how-i-made-my-own-songs-with-suno-ai': {
|
||||
titlebar: 'hoelee@studio — suno v6 · 7 songs',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'write lyrics · craft style prompt (200 chars)' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: '[Verse][Chorus][Bridge] · [Whispered][Belted]' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'generate 3–5 takes → keep best' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'AI vocalist mispronounces 忘川 — respell test clip' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'v6: section edit · single-line swap · voices' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 7 songs hosted · embedded above ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'lyrics first' },
|
||||
{ n: '2', label: 'style prompt' },
|
||||
{ n: '3', label: 'metatags' },
|
||||
{ n: '4', label: 'iterate takes' },
|
||||
{ n: '5', label: 'ship ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'passbolt-hang-three-failure-modes': {
|
||||
titlebar: 'root@dsm — passbolt incident',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'GET / → 504 Gateway Timeout (every minute, worse under load)' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'cron: job is still running since ... (1m elapsed)' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'SMTP Setting errors: fingerprint null' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = bind-mount passbolt.php · full fingerprint · remove ssl.force' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ cron 0.4s · UI 302 · healthcheck green ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: '504 hang' },
|
||||
{ n: '2', label: 'fingerprint null', err: true },
|
||||
{ n: '3', label: 'mount config' },
|
||||
{ n: '4', label: 'redirect loop', err: true },
|
||||
{ n: '5', label: 'fixed ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'unraid-stop-array-hangs-on-swapfile': {
|
||||
titlebar: 'root@unraid — array stop incident',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'WebUI → Stop array · swapfile lives on /mnt/cache (btrfs RAID1)' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Retry unmounting user shares… · umount: target is busy (every 5s, forever)' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: '/proc/swaps lists /dev/loop0 — grep swapfile never matches' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = User Scripts: swapoff -a + losetup -j/-d at stopping_svcs · swapon at disks_mounted' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ clean unmount on first try · swap survives stop/start ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'stop array' },
|
||||
{ n: '2', label: 'EBUSY loop', err: true },
|
||||
{ n: '3', label: 'losetup -j' },
|
||||
{ n: '4', label: 'swapoff hook' },
|
||||
{ n: '5', label: 'clean stop ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'the-cause-was-trim-not-the-ssds': {
|
||||
titlebar: 'root@unraid — ssd pool trim watch',
|
||||
lines: [
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'raw read error rate (failing now) is 19665 — sdd SMART trip' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'btrfs device stats /mnt/ssd' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'corruption_errs sdd1=27 sdb1=31 · csum 0x8941f998 = CRC32C(zeros) · both mirrors' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = diskAutotrim="off" · remount,nodiscard · scrub' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ scrub #2: corrected 0 · counters flat · cause = queued TRIM firmware bug ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'SMART trip' },
|
||||
{ n: '2', label: 'zeros on both mirrors', err: true },
|
||||
{ n: '3', label: 'queued TRIM' },
|
||||
{ n: '4', label: 'autotrim off' },
|
||||
{ n: '5', label: 'scrub clean ✓' },
|
||||
],
|
||||
},
|
||||
|
||||
'that-dying-ssd-was-just-a-bad-sata-cable': {
|
||||
titlebar: 'root@unraid — sdd mkfs attempt',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'mkfs.btrfs -K -f /dev/sdd1' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'ata7.00: WRITE FPDMA QUEUED timeout · NCQ disabled · lost async page write' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'ERROR: superblock magic doesn\'t match · smartctl -H timeout' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'fix = new SATA cable + different port · rerun the same mkfs' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ clean format · 8 GiB fio verify=crc32c err=0 · device stats all zero ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: '"dying" SSD' },
|
||||
{ n: '2', label: 'FPDMA timeouts', err: true },
|
||||
{ n: '3', label: 'cable/port swap' },
|
||||
{ n: '4', label: 'rerun mkfs' },
|
||||
{ n: '5', label: 'clean ✓' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_BANNER = {
|
||||
titlebar: 'root@host — shell',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'engineering · devops · self-hosting' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: 'read the full post →' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'start' }, { n: '2', label: 'work' }, { n: '3', label: 'ship' },
|
||||
],
|
||||
};
|
||||
|
||||
BANNERS['n8n-v1-to-v2-upgrade-gotchas'] = {
|
||||
titlebar: 'root@dsm — n8n v1 → v2 migration',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker pull n8nio/n8n:2.40.1 · container up in 90s' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'v1.123.x → v2.40.1 · 17 workflows · 7 active' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Telemetry failed schema validation: executions_data_save_on_error' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Failed to start Python task runner — Python 3 missing' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'Sandbox: enabled=false (DB override; env was enabled=true)' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'N8N_WEBHOOK_URL · pin TASK_TIMEOUT=300 · pin compression limits' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'storage rename flagged for v3 · migrate + remount together' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 7 deprecations resolved · downstream workflows intact ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'pull v2' },
|
||||
{ n: '2', label: 'read boot log' },
|
||||
{ n: '3', label: 'schema reject', err: true },
|
||||
{ n: '4', label: 'pin defaults' },
|
||||
{ n: '5', label: 'verified ✓' },
|
||||
],
|
||||
};
|
||||
|
||||
BANNERS['self-healing-digital-goods-entitlements'] = {
|
||||
titlebar: 'root@dsm — entitlement lifecycle (W1–W5)',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'nocodb webhook → n8n compute → alist role scopes' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'desired = union(active purchases, direct grants) · MAX expiry wins' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'shared/effective-grants.js inlined at build · 4 workflows' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'public hostname: >60s → nginx 504 → 1s retries → pool → 503' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'http://nocodb:10380 (30ms, no tunnel) → cascade gone' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'W3 sweeps every 5min · W4 repairs drift 03:00 + verifies' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'W5 public: CORS-locked, minimal fields, no HMAC theatre' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ repair verified · reconcile log empty when healthy ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'grant' },
|
||||
{ n: '2', label: 'expire' },
|
||||
{ n: '3', label: 'drift', err: true },
|
||||
{ n: '4', label: 'repair' },
|
||||
{ n: '5', label: 'verify ✓' },
|
||||
],
|
||||
};
|
||||
|
||||
BANNERS['running-tts-as-a-service-with-token-sidecars'] = {
|
||||
titlebar: 'root@dsm — tts service · 1 year uptime',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'reader → GET /webhook/{mtts,gtts}?pass=…&text=…&speed=…' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'azure speech F0 · google cloud cmn-CN-Wavenet-A' },
|
||||
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'token stored in workflow → 401 after 10min (azure) / 1h (google)' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: '2 cron sidecars → accesstoken.txt on shared volume' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'refresh 570s / 3500s · margin before expiry' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'map speed 5–50 → -20%…+150% · strip trailing newline' },
|
||||
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: '8 neural voices selected by integer index' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ secrets in zero workflows · still running ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'webhook' },
|
||||
{ n: '2', label: 'read token' },
|
||||
{ n: '3', label: 'synthesize' },
|
||||
{ n: '4', label: 'audio/wav' },
|
||||
{ n: '5', label: '1yr ✓' },
|
||||
],
|
||||
};
|
||||
|
||||
BANNERS['self-hosted-speech-to-text-api'] = {
|
||||
titlebar: 'root@gpu-pc — whisper.cpp',
|
||||
lines: [
|
||||
{ t: 'cmd', text: 'netstat -an | grep 20129' },
|
||||
{ t: 'ok', text: 'TCP 127.0.0.1:20129 LISTENING ← only this PC' },
|
||||
{ t: 'dim', text: 'iPhone · iPad · Android · work PCs ?' },
|
||||
{ t: 'cmd', text: '--host 0.0.0.0 + firewall -RemoteAddress LocalSubnet' },
|
||||
{ t: 'ok', text: 'TCP 0.0.0.0:20129 LISTENING ← reachable' },
|
||||
{ t: 'cmd', text: 'n8n gate: Authorization header → per-device key' },
|
||||
{ t: 'err', text: 'bad key → 403' },
|
||||
{ t: 'hl', text: '{"text":"…"} large-v3 on RTX 3060 · 130 wpm' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'phone dictates' },
|
||||
{ n: '2', label: 'HTTPS + key' },
|
||||
{ n: '3', label: 'n8n gate' },
|
||||
{ n: '4', label: 'GPU transcribe' },
|
||||
{ n: '5', label: '5x typing ✓' },
|
||||
],
|
||||
};
|
||||
|
||||
BANNERS['replacing-rdpguard-with-ipban'] = {
|
||||
titlebar: 'root@win11 — ipban service',
|
||||
lines: [
|
||||
{ t: 'cmd', text: 'sc.exe qc IPBAN' },
|
||||
{ t: 'ok', text: 'START_TYPE : 2 AUTO_START ✓' },
|
||||
{ t: 'dim', text: 'RdpGuard 7.8.7 — paid, closed, 3 versions behind' },
|
||||
{ t: 'err', text: 'uninstall → rule rdpguard-… deleted → 12 bans LOST' },
|
||||
{ t: 'cmd', text: 'ban.txt → IPBan import (12 ipv4)' },
|
||||
{ t: 'ok', text: 'Updating firewall with 12 entries...' },
|
||||
{ t: 'err', text: "ipban --install-service → 'Unrecognized command'" },
|
||||
{ t: 'hl', text: '16 attackers blocked · zero protection gap' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'export bans' },
|
||||
{ n: '2', label: 'sc.exe install' },
|
||||
{ n: '3', label: 'whitelist LAN' },
|
||||
{ n: '4', label: '16 blocked ✓' },
|
||||
],
|
||||
};
|
||||
|
||||
BANNERS['patching-workbench-26-for-mariadb'] = {
|
||||
titlebar: 'root@win11 — workbench 26.7.0',
|
||||
lines: [
|
||||
{ t: 'cmd', text: 'workbench → mariadb 192.168.1.124:3306' },
|
||||
{ t: 'err', text: 'TypeError: on_session_message() missing 1 arg' },
|
||||
{ t: 'dim', text: 'the error handler crashed reporting the error' },
|
||||
{ t: 'err', text: "ERROR 1193: Unknown system variable 'gtid_mode'" },
|
||||
{ t: 'dim', text: 'MariaDB 10.11 → nversion 101119 · major >= 8 guard passes' },
|
||||
{ t: 'cmd', text: 'patch replication.py · DbSession.py · SetupTasks.py' },
|
||||
{ t: 'err', text: "ERROR 1193: 'explain_json_format_version' — again" },
|
||||
{ t: 'hl', text: '4 connections · mysqlsh proved the server was fine' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'mysqlsh test' },
|
||||
{ n: '2', label: 'fix handler' },
|
||||
{ n: '3', label: '3 patches' },
|
||||
{ n: '4', label: 'connected ✓' },
|
||||
],
|
||||
};
|
||||
|
||||
// ---------- read frontmatter ----------
|
||||
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
|
||||
let category = 'devops';
|
||||
if (existsSync(postPath)) {
|
||||
const raw = readFileSync(postPath, 'utf8');
|
||||
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
|
||||
const c = fm.match(/^category\s*:\s*(.+)$/m)?.[1]?.trim();
|
||||
if (c) category = c.replace(/["']/g, '');
|
||||
}
|
||||
|
||||
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// render terminal lines
|
||||
function renderLines(lines) {
|
||||
// group into visual lines: each entry has t + text; consecutive dim/prompt/cmd share a row
|
||||
let html = '';
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const l = lines[i];
|
||||
if (l.t === 'prompt') {
|
||||
// prompt may be followed by a cmd/err/ok/hl/dim on same row
|
||||
const next = lines[i + 1];
|
||||
const promptClass = l.text === '$' ? 'prompt' : 'prompt';
|
||||
let row = `<span class="${promptClass}">${esc(l.text) || ' '}</span>`;
|
||||
if (next && next.t !== 'prompt') {
|
||||
row += `<span class="${next.t}">${esc(next.text)}</span>`;
|
||||
i += 1;
|
||||
}
|
||||
html += `<div class="line" style="margin-top:6px;">${row}</div>`;
|
||||
} else {
|
||||
html += `<div class="line" style="margin-top:6px;"><span class="${l.t}">${esc(l.text)}</span></div>`;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderFlow(flow) {
|
||||
let html = '';
|
||||
flow.forEach((s, idx) => {
|
||||
html += `<div class="step${s.err ? ' err-step' : ''}"><span class="num">${s.n}</span>${esc(s.label)}</div>`;
|
||||
if (idx < flow.length - 1) html += `<div class="arrow">→</div>`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
const cfg = BANNERS[slug] || DEFAULT_BANNER;
|
||||
const logoPath = 'file:///' + join(ROOT, 'public', 'logo-square.png').replace(/\\/g, '/');
|
||||
|
||||
let tpl = readFileSync(join(__dirname, 'template.html'), 'utf8');
|
||||
tpl = tpl
|
||||
.replace('{{CATEGORY}}', esc(category))
|
||||
.replace('{{SLUG}}', esc(slug))
|
||||
.replace('{{LOGO_PATH}}', logoPath)
|
||||
.replace('{{TITLEBAR}}', esc(cfg.titlebar))
|
||||
.replace('{{TERMINAL_HTML}}', renderLines(cfg.lines))
|
||||
.replace('{{FLOW_HTML}}', renderFlow(cfg.flow));
|
||||
|
||||
mkdirSync(join(ROOT, '.og', 'gen'), { recursive: true });
|
||||
const htmlPath = join(ROOT, '.og', 'gen', `${slug}-banner.html`);
|
||||
writeFileSync(htmlPath, tpl);
|
||||
|
||||
const chrome = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
||||
const outPng = join(ROOT, '.og', 'gen', `${slug}-banner.png`);
|
||||
try {
|
||||
execSync(`"${chrome}" --headless --disable-gpu --force-device-scale-factor=1 --window-size=1600,900 --virtual-time-budget=3000 --screenshot="${outPng}" "file:///${htmlPath.replace(/\\/g, '/')}"`, { stdio: 'pipe' });
|
||||
} catch (e) {
|
||||
console.error('Chrome render failed:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const publicDir = join(ROOT, 'public', 'banners');
|
||||
mkdirSync(publicDir, { recursive: true });
|
||||
const finalPng = join(publicDir, `${slug}.png`);
|
||||
execSync(`copy /Y "${outPng}" "${finalPng}"`, { stdio: 'pipe' });
|
||||
|
||||
console.log(`✓ Banner generated: public/banners/${slug}.png`);
|
||||
console.log(` category: ${category}`);
|
||||
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1600px; height: 900px; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
background: #0d1220;
|
||||
position: relative;
|
||||
padding: 48px 52px;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
}
|
||||
.glow.g1 { width: 500px; height: 500px; background: #295cff; opacity: 0.28; top: -180px; right: -120px; }
|
||||
.glow.g2 { width: 400px; height: 400px; background: #0e94ff; opacity: 0.20; bottom: -150px; left: -120px; }
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.topbar .crumb {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
color: #6b7c99; font-size: 18px;
|
||||
}
|
||||
.topbar .crumb .pipe { color: #2a3550; }
|
||||
.topbar .crumb .active { color: #e6edf3; }
|
||||
.topbar .logo { width: 40px; height: 40px; background: #fff; border-radius: 8px; padding: 3px; }
|
||||
.topbar .logo img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
.terminal {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 690px;
|
||||
background: rgba(22, 30, 46, 0.95);
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 70px rgba(0,0,0,0.45);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal .titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 22px;
|
||||
background: #131a2a;
|
||||
border-bottom: 1px solid #2a3550;
|
||||
}
|
||||
.terminal .dot { width: 13px; height: 13px; border-radius: 50%; }
|
||||
.terminal .dot.r { background: #ff5c5c; }
|
||||
.terminal .dot.y { background: #f5a524; }
|
||||
.terminal .dot.g { background: #3fb950; }
|
||||
.terminal .title-text { margin-left: 12px; color: #6b7c99; font-size: 16px; }
|
||||
|
||||
.terminal .body { padding: 30px 36px; font-size: 22px; line-height: 1.8; flex: 1; display: flex; flex-direction: column; justify-content: center; }
|
||||
.terminal .line { display: flex; gap: 12px; margin-bottom: 8px; }
|
||||
.terminal .prompt { color: #7599ff; flex-shrink: 0; }
|
||||
.terminal .cmd { color: #e6edf3; }
|
||||
.terminal .dim { color: #6b7c99; }
|
||||
.terminal .err {
|
||||
color: #ff5c5c;
|
||||
background: rgba(255,92,92,0.10);
|
||||
padding: 2px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.terminal .ok { color: #3fb950; }
|
||||
.terminal .hl { color: #f5a524; }
|
||||
|
||||
.flow {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.flow .step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 14px 8px;
|
||||
background: #161e2e;
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 10px;
|
||||
color: #e6edf3;
|
||||
font-size: 17px;
|
||||
}
|
||||
.flow .step .num { display: block; color: #7599ff; font-size: 13px; margin-bottom: 4px; }
|
||||
.flow .step.err-step { border-color: #ff5c5c; }
|
||||
.flow .step.err-step .num { color: #ff5c5c; }
|
||||
.flow .arrow { color: #2a3550; font-size: 22px; flex-shrink: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="glow g1"></div>
|
||||
<div class="glow g2"></div>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="crumb">
|
||||
<span>blog.hoelee.com</span><span class="pipe">/</span>
|
||||
<span>{{CATEGORY}}</span><span class="pipe">/</span>
|
||||
<span class="active">{{SLUG}}</span>
|
||||
</div>
|
||||
<div class="logo"><img src="{{LOGO_PATH}}" alt=""></div>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="titlebar">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
<span class="title-text">{{TITLEBAR}}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{{TERMINAL_HTML}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
{{FLOW_HTML}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
# og-gen — per-post OG image generator
|
||||
|
||||
Generates a branded 1200×630 social-share image for a blog post, matching the
|
||||
site's cobalt-blue `#295cff` branding, the `logo-square.png` mark, and the
|
||||
"terminal / debugging" visual style.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
node scripts/og-gen/generate.mjs <post-slug>
|
||||
```
|
||||
|
||||
Reads frontmatter (`title`, `category`, `tags`) from
|
||||
`src/content/posts/<slug>.md`, fills `template.html`, renders with headless
|
||||
Chrome, and writes `public/og/<slug>.png`.
|
||||
|
||||
The post's frontmatter needs `ogImage: /og/<slug>.png` so the blog serves it
|
||||
(layout already renders `og:image` + width/height; PostList shows it as a
|
||||
card thumbnail).
|
||||
|
||||
## Customizing the terminal box
|
||||
|
||||
Each post shows a "terminal" panel (the red error / green fix lines). Add an
|
||||
entry keyed by slug in `TERMINALS` inside `generate.mjs`, using these tokens:
|
||||
|
||||
```html
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">command</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">error line</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">fix</span><span class="fix">→ done ✓</span></div>
|
||||
```
|
||||
|
||||
No entry → a generic default terminal.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Google Chrome at `C:\Program Files\Google\Chrome\Application\chrome.exe`
|
||||
(override with `CHROME_PATH` env var).
|
||||
- Node 20+.
|
||||
|
||||
## Notes
|
||||
|
||||
- PNG (not JPG) is intentional: for flat UI/terminal art, PNG is smaller and
|
||||
sharper than JPG (see commit history).
|
||||
- The `logo-square.png` referenced lives at `public/logo-square.png`.
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* og-gen — generate a 1200x630 OG image for a blog post.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/og-gen/generate.mjs <slug>
|
||||
*
|
||||
* Reads the post's frontmatter from src/content/posts/<slug>.md (title,
|
||||
* category, tags), fills the HTML template, and renders it to
|
||||
* public/og/<slug>.png via headless Chrome.
|
||||
*
|
||||
* Terminal content (the red "error" line + green "fix" line) is looked up in
|
||||
* TERMINALS below by slug; fall back to a generic default if absent.
|
||||
*
|
||||
* Requirements: Google Chrome installed at the default Windows path.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..');
|
||||
|
||||
const slug = process.argv[2];
|
||||
if (!slug) {
|
||||
console.error('Usage: node scripts/og-gen/generate.mjs <slug>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------- per-post terminal content (tune per post) ----------
|
||||
// Each entry renders inside the terminal box. Use these tokens:
|
||||
// {cmd} → a normal command line
|
||||
// {err} → a red error line
|
||||
// {fix} → a green fix line
|
||||
const TERMINALS = {
|
||||
'why-telegram-bot-notifications-die': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">docker exec monitor python check_notify.py</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">telegram sendPhoto failed: 400 nginx/1.30.1</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">fix = IPv6 · DNS · multipart</span><span class="fix">→ delivered ✓</span></div>`,
|
||||
|
||||
'hello-world': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">git init hoelee-blog · first commit</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="fix">technical writing · self-hosting · build log</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">about → blog.hoelee.com</span></div>`,
|
||||
|
||||
'how-i-host-this-blog': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">git push origin main</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="fix">Gitea Actions → unRaid runner → nginx → Cloudflare ✓</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">git-as-CMS · zero-downtime deploy</span></div>`,
|
||||
|
||||
'how-i-built-the-digikedai-telegram-bot': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">user:"does this course have a free trial?"</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="fix">bot → yes, here's your account ✓ (24/7, no human)</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">grammY · LiteLLM · Cloudflare tunnel</span></div>`,
|
||||
|
||||
'authentik-major-upgrade-gotchas': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">docker compose pull authentik-worker</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">authorization_flow not found · SSO broken</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">uptime: 2025.8 → 2026.8</span><span class="fix">fixed ✓</span></div>`,
|
||||
|
||||
'why-your-headless-browser-cant-scrape-everything': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">browserless → goofish search</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">非法访问 · product list stuck "loading…"</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">carousell: server-rendered JSON</span><span class="fix">→ parsed ✓</span></div>`,
|
||||
|
||||
'the-nocodb-attachment-that-wouldnt-update': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">PATCH image path → 300 rows backfill</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">path unchanged · keep the id, keep the old URL</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">strip id · replace() suffix</span><span class="fix">→ updated ✓</span></div>`,
|
||||
|
||||
'using-chinese-llm-apis-from-malaysia': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">curl tokenrhythm.studio/v1/chat/completions</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">403 · mainland CN phone required</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">Alipay RMB · OpenAI-compatible key</span><span class="fix">→ ¥68 credit ✓</span></div>`,
|
||||
|
||||
'how-to-verify-a-hosting-provider-before-you-buy': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">curl -s rdap.org/domain/vps.tld | jq .events</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">registration: 2026-05 · "trusted since 2012"</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">AUP grep tor · reviews · retention</span><span class="fix">→ verdict ✓</span></div>`,
|
||||
|
||||
'how-i-vetted-20-vps-providers-with-parallel-subagents': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">fan-out → 3 subagents × 20 providers</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="fix">whois · AUP · reviews · retention — in parallel</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">merge scorecard · rank · audit trail</span><span class="fix">→ verdict ✓</span></div>`,
|
||||
|
||||
'automating-cyberpanel-without-the-ui': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">POST /api/verifyConnection</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">404 · API prefix dropped in v2</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">csrftoken → /verifyLogin → fetchWebsitesList</span><span class="fix">→ sites ✓</span></div>`,
|
||||
|
||||
'hardening-a-tor-onion-service': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">wget -qO- ipv4.icanhazip.com</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">bind: permission denied · CapEff=0 on :80</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">internal:true · :8080 · SocksPort 0</span><span class="fix">→ zero egress ✓</span></div>`,
|
||||
|
||||
'syncing-a-self-improving-ai-agent-across-machines': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">git pull --ff-only origin main</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">refusing: local divergence · never --force</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">diff -rq live skills · merge on conflict</span><span class="fix">→ synced ✓</span></div>`,
|
||||
|
||||
'best-ai-video-generators-2026': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">video_gen --free --compare --2026</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">"free" = 4 deals · credits ≠ seconds · Sora 2 sunset 09-24</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">fix = LTX-2.3 test embed · pricing checked 2026-09-11</span><span class="fix">→ shipped ✓</span></div>`,
|
||||
|
||||
'ai-furniture-compositing-with-flux-kontext': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">fetch fal-ai/flux-pro/kontext/multi · 2 photos in</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">toDataURL: tainted canvas · may not be exported</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">enhance_prompt:false · same-origin paths</span><span class="fix">→ 1 room out ✓</span></div>`,
|
||||
|
||||
'how-i-made-my-own-songs-with-suno-ai': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">suno "indie folk · playful whistles · sprite-girl vocals"</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="fix">lyrics + metatags → 7 songs shipped ✓</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">v6: section edits · single-line swaps · voices</span></div>`,
|
||||
|
||||
'passbolt-hang-three-failure-modes': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">curl -I https://pass.hoelee.com</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">504 · ERR_TOO_MANY_REDIRECTS · fingerprint null</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">mount passbolt.php · full fingerprint</span><span class="fix">→ fixed ✓</span></div>`,
|
||||
|
||||
'scraping-bot-walled-marketplace-warm-browser-session': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">CDP → shopee search "used phone" · client marketplace monitor</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">/verify captcha · empty product cards</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">warm session · 7s pacing · sweep.py</span><span class="fix">→ 20+ listings ✓</span></div>`,
|
||||
|
||||
'unraid-stop-array-hangs-on-swapfile': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">unraid → stop array · swapfile on /mnt/cache</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">umount: target is busy · /proc/swaps says /dev/loop0</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">swapoff -a + losetup -j at stopping_svcs</span><span class="fix">→ clean stop ✓</span></div>`,
|
||||
|
||||
'when-smart-says-healthy-but-your-raid-is-corrupting-data': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">scrub → re-read vdisk1.img</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">csum 0x8941f998 = CRC32C(zeros) · recurring</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">self-heal rewrite does NOT stick</span><span class="fix">→ replace both drives ✓</span></div>`,
|
||||
|
||||
'self-hosting-mem0-memory-stack': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">curl -X POST :20015/memories · X-Api-Key</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">infer=true → LLM hop · slow write</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">infer=false · pgvector · LiteLLM gateway</span><span class="fix">→ remembers across chats ✓</span></div>`,
|
||||
|
||||
'shipping-an-ai-photo-editor-as-a-wordpress-plugin': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">wp plugin list · hre-ai-remix 0.0.1 → 0.0.22</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">404 …/wp-json/hre/v1<span class="hl">admin</span>/photos — rest_url() has no trailing slash</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">fix = '/admin/…' · 58 commits · 4 were the AI</span><span class="fix">→ shipped ✓</span></div>`,
|
||||
|
||||
'n8n-v1-to-v2-upgrade-gotchas': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">pull n8nio/n8n:2.40.1 · restart · upgrade took 90s</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">telemetry schema: executions_data_save_on_error rejected</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">read the boot log · pin timeouts + limits</span><span class="fix">→ 7 fixed ✓</span></div>`,
|
||||
|
||||
'self-healing-digital-goods-entitlements': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">nocodb → n8n W1–W5 → alist role scopes</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">public hostname: 60s latency → nginx 504 → retry storm → 503</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">http://nocodb:10380 · 30ms · W4 repairs drift 03:00</span><span class="fix">→ self-healing ✓</span></div>`,
|
||||
|
||||
'running-tts-as-a-service-with-token-sidecars': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">reading app → GET /webhook/mtts?pass=…&text=…</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">azure token expires in ~10min · google in ~1h</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">2 cron sidecars write accesstoken.txt · 570s / 3500s</span><span class="fix">→ 1 year uptime ✓</span></div>`,
|
||||
|
||||
'the-cause-was-trim-not-the-ssds': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">btrfs device stats /mnt/ssd</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">corruption_errs sdd1=27 sdb1=31 · csum 0x8941f998 = CRC32C(zeros) · both mirrors</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">diskAutotrim="off" · remount,nodiscard · scrub</span><span class="fix">→ 0 new errors ✓</span></div>`,
|
||||
|
||||
'that-dying-ssd-was-just-a-bad-sata-cable': `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">mkfs.btrfs -K -f /dev/sdd1</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">WRITE FPDMA QUEUED timeouts · superblock magic doesn't match</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">swap SATA cable/port · rerun mkfs</span><span class="fix">→ clean · 0 errors ✓</span></div>`,
|
||||
};
|
||||
|
||||
TERMINALS['self-hosted-speech-to-text-api'] = `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">whisper-server --host 0.0.0.0 --port 20129 · large-v3 · RTX 3060</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">connect ETIMEDOUT 192.168.1.123:20129 — bound to 127.0.0.1 only</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">bind 0.0.0.0 · firewall LocalSubnet · n8n key gate</span><span class="fix">→ 130 wpm ✓</span></div>`;
|
||||
|
||||
TERMINALS['replacing-rdpguard-with-ipban'] = `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">ipban --install-service</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">Unrecognized command or argument '--install-service'</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">sc.exe create IPBAN type= own start= auto binPath= ...</span><span class="fix">→ AUTO_START ✓</span></div>`;
|
||||
|
||||
TERMINALS['patching-workbench-26-for-mariadb'] = `
|
||||
<div class="line"><span class="prompt"> </span><span class="err">TypeError: on_session_message() missing 1 required argument</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">select @@gtid_mode · MariaDB 10.11</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">ERROR 1193: Unknown system variable 'gtid_mode'</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">3 patches · 4 connections</span><span class="fix">→ connected ✓</span></div>`;
|
||||
|
||||
const DEFAULT_TERMINAL = `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">engineering · devops · self-hosting</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="fix">read the full post →</span></div>`;
|
||||
|
||||
// ---------- read frontmatter from the post ----------
|
||||
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
|
||||
if (!existsSync(postPath)) {
|
||||
console.error(`Post not found: ${postPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const raw = readFileSync(postPath, 'utf8');
|
||||
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
|
||||
const get = (key) => {
|
||||
const m = fm.match(new RegExp(`^${key}\\s*:\\s*(.+)$`, 'm'));
|
||||
if (!m) return undefined;
|
||||
let v = m[1].trim();
|
||||
// strip quotes
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||
if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
|
||||
return v;
|
||||
};
|
||||
const title = get('title') || slug.replace(/-/g, ' ');
|
||||
const category = get('category') || 'devops';
|
||||
const tagsRaw = get('tags') || '[]';
|
||||
let tags = [];
|
||||
// tags is a YAML flow array: [docker, telegram, "cloudflare"]
|
||||
const stripped = tagsRaw.replace(/^\[|\]$/g, '');
|
||||
tags = stripped
|
||||
.split(',')
|
||||
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
// ---------- build template substitutions ----------
|
||||
// split the long title into two balanced lines for readability
|
||||
const words = title.split(' ');
|
||||
const mid = Math.ceil(words.length / 2);
|
||||
const line1 = words.slice(0, mid).join(' ');
|
||||
const line2 = words.slice(mid).join(' ');
|
||||
// escape HTML
|
||||
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
// title size: short titles can be larger
|
||||
const titleLen = title.length;
|
||||
const titleSize = titleLen > 60 ? 40 : titleLen > 40 ? 46 : 52;
|
||||
const titleHtml = `${esc(line1)}<br>${esc(line2)}`;
|
||||
|
||||
const terminalHtml = TERMINALS[slug] || DEFAULT_TERMINAL;
|
||||
|
||||
const tagsHtml = tags.map((t) => `<span>#${esc(t)}</span>`).join('');
|
||||
|
||||
const logoPath = 'file:///' + join(ROOT, 'public', 'logo-square.png').replace(/\\/g, '/');
|
||||
|
||||
// the tag chip shows "<category> · <kind>"; kind is a human label for the section.
|
||||
// Were KIND to be hardcoded, every non-devops post would carry a wrong label.
|
||||
const KIND_BY_CATEGORY = {
|
||||
engineering: 'Engineering',
|
||||
devops: 'DevOps',
|
||||
ai: 'AI',
|
||||
web3: 'Web3',
|
||||
tutorials: 'Tutorials',
|
||||
'case-studies': 'Case Study',
|
||||
notes: 'Notes',
|
||||
};
|
||||
const kind = KIND_BY_CATEGORY[category] || category;
|
||||
|
||||
let tpl = readFileSync(join(__dirname, 'template.html'), 'utf8');
|
||||
tpl = tpl
|
||||
.replace('{{TITLE_SIZE}}', String(titleSize))
|
||||
.replace('{{LOGO_PATH}}', logoPath)
|
||||
.replace('{{CATEGORY}}', esc(category === kind.toLowerCase() ? category : kind))
|
||||
.replace('{{KIND}}', '')
|
||||
.replace('{{TITLE_HTML}}', titleHtml)
|
||||
.replace('{{TERMINAL_HTML}}', terminalHtml)
|
||||
.replace('{{TAGS_HTML}}', tagsHtml);
|
||||
|
||||
// write temp html
|
||||
mkdirSync(join(ROOT, '.og', 'gen'), { recursive: true });
|
||||
const htmlPath = join(ROOT, '.og', 'gen', `${slug}.html`);
|
||||
writeFileSync(htmlPath, tpl);
|
||||
|
||||
// render with headless Chrome
|
||||
const chrome = process.env.CHROME_PATH ||
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
||||
const outPng = join(ROOT, '.og', 'gen', `${slug}.png`);
|
||||
try {
|
||||
execSync(`"${chrome}" --headless --disable-gpu --force-device-scale-factor=1 --window-size=1200,900 --virtual-time-budget=3000 --screenshot="${outPng}" "file:///${htmlPath.replace(/\\/g, '/')}"`, { stdio: 'pipe' });
|
||||
} catch (e) {
|
||||
console.error('Chrome render failed:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// move to public/og/
|
||||
const publicDir = join(ROOT, 'public', 'og');
|
||||
mkdirSync(publicDir, { recursive: true });
|
||||
const finalPng = join(publicDir, `${slug}.png`);
|
||||
|
||||
sharp(outPng)
|
||||
.extract({ left: 0, top: 0, width: 1200, height: 630 })
|
||||
.toFile(finalPng)
|
||||
.then(() => {
|
||||
console.log(`✓ OG image generated: public/og/${slug}.png`);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('crop failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
console.log(` title: ${title}`);
|
||||
console.log(` category: ${category} | tags: ${tags.join(', ')}`);
|
||||
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 1200px; height: 630px; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
background: #0d1220;
|
||||
color: #e6edf3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 34px 48px 0;
|
||||
}
|
||||
.avatar {
|
||||
width: 48px; height: 48px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #ffffff; padding: 4px;
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.brand {
|
||||
font-family: "Space Grotesk", "Plus Jakarta Sans", sans-serif;
|
||||
font-weight: 700; font-size: 22px; color: #ffffff; letter-spacing: 0.3px;
|
||||
}
|
||||
.brand .domain { color: #6b7c99; font-weight: 500; font-size: 19px; }
|
||||
|
||||
.tag {
|
||||
margin: 40px 48px 0;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
width: fit-content; padding: 7px 16px; border-radius: 6px;
|
||||
background: rgba(41, 92, 255, 0.16);
|
||||
border: 1px solid rgba(41, 92, 255, 0.5);
|
||||
color: #7599ff; font-size: 16px; letter-spacing: 0.8px; text-transform: uppercase;
|
||||
}
|
||||
.tag .dot { width: 8px; height: 8px; border-radius: 50%; background: #295cff; }
|
||||
|
||||
.title {
|
||||
margin: 30px 48px 0;
|
||||
font-family: "Space Grotesk", "Plus Jakarta Sans", sans-serif;
|
||||
font-weight: 700; font-size: {{TITLE_SIZE}}px;
|
||||
line-height: 1.15; color: #ffffff; max-width: 1000px;
|
||||
}
|
||||
.title .accent { color: #ff5c5c; }
|
||||
|
||||
.terminal {
|
||||
margin: 34px 48px 0;
|
||||
background: #161e2e; border: 1px solid #2a3550; border-radius: 10px;
|
||||
padding: 22px 28px;
|
||||
font-family: "JetBrains Mono", "Fira Code", Menlo, monospace;
|
||||
font-size: 21px; line-height: 1.6; max-width: 1040px;
|
||||
}
|
||||
.terminal .line { display: flex; gap: 12px; }
|
||||
.terminal .prompt { color: #7599ff; }
|
||||
.terminal .cmd { color: #e6edf3; }
|
||||
.terminal .err { color: #ff5c5c; background: rgba(255, 92, 92, 0.10); padding: 1px 8px; border-radius: 4px; }
|
||||
.terminal .fix { color: #3fb950; }
|
||||
|
||||
.footer {
|
||||
position: absolute; left: 0; right: 0; bottom: 0;
|
||||
padding: 0 48px 34px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.footer .tags { color: #6b7c99; font-size: 17px; }
|
||||
.footer .tags span { margin-right: 18px; color: #7599ff; }
|
||||
.footer .url { color: #6b7c99; font-size: 17px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div class="avatar"><img src="{{LOGO_PATH}}" alt=""></div>
|
||||
<div class="brand">Mr Hoelee <span class="domain">· blog.hoelee.com</span></div>
|
||||
</div>
|
||||
|
||||
<div class="tag"><span class="dot"></span>{{CATEGORY}}</div>
|
||||
|
||||
<div class="title">{{TITLE_HTML}}</div>
|
||||
|
||||
<div class="terminal">
|
||||
{{TERMINAL_HTML}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="tags">{{TAGS_HTML}}</div>
|
||||
<div class="url">blog.hoelee.com</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,181 @@
|
||||
---
|
||||
/**
|
||||
* CategoryArt — terminal-style SVG illustration per category.
|
||||
* Matches the banner-gen visual language (dark #0d1220 canvas, terminal
|
||||
* window with traffic-light dots, JetBrains Mono, status colors) but with a
|
||||
* per-category accent color, so each category is scannable at a glance.
|
||||
* Inline SVG inherits the blog's self-hosted JetBrains Mono for text lines.
|
||||
*/
|
||||
import { categoryMeta } from '../lib/categories';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const { slug } = Astro.props;
|
||||
const meta = categoryMeta(slug);
|
||||
|
||||
// line types: prompt (accent), cmd #e6edf3, ok #3fb950, hl #f5a524, dim #6b7c99
|
||||
type Line = { t: 'prompt' | 'cmd' | 'ok' | 'hl' | 'dim'; c: string };
|
||||
|
||||
const ART: Record<string, { title: string; lines: Line[]; glyph: 'text' | 'check'; gc: string }> = {
|
||||
ai: {
|
||||
title: 'n8n — ai workflows',
|
||||
lines: [
|
||||
{ t: 'dim', c: 'telegram ⇄ llm ⇄ memory' },
|
||||
{ t: 'prompt', c: '>' },
|
||||
{ t: 'cmd', c: ' user: "help me book a slot"' },
|
||||
{ t: 'ok', c: 'bot: done · calendar updated' },
|
||||
{ t: 'hl', c: 'mem0 long-term memory · on' },
|
||||
],
|
||||
glyph: 'text',
|
||||
gc: 'λ',
|
||||
},
|
||||
'case-studies': {
|
||||
title: 'mr@hoelee — portfolio',
|
||||
lines: [
|
||||
{ t: 'dim', c: 'drwxr-xr-x hoelee projects' },
|
||||
{ t: 'prompt', c: '$' },
|
||||
{ t: 'cmd', c: ' cd digikedai-bot && ship' },
|
||||
{ t: 'ok', c: 'deployed · measured · written up' },
|
||||
{ t: 'hl', c: '→ end-to-end build logs' },
|
||||
],
|
||||
glyph: 'check',
|
||||
gc: '',
|
||||
},
|
||||
devops: {
|
||||
title: 'root@homelab',
|
||||
lines: [
|
||||
{ t: 'dim', c: '~140 containers · 6 hosts' },
|
||||
{ t: 'prompt', c: '$' },
|
||||
{ t: 'cmd', c: ' docker compose up -d' },
|
||||
{ t: 'ok', c: 'traefik · pihole · n8n — Up' },
|
||||
{ t: 'ok', c: 'backups 3-2-1 · verified' },
|
||||
],
|
||||
glyph: 'text',
|
||||
gc: '$',
|
||||
},
|
||||
engineering: {
|
||||
title: 'src — spring & wordpress',
|
||||
lines: [
|
||||
{ t: 'dim', c: 'Java · PHP · TypeScript' },
|
||||
{ t: 'prompt', c: '$' },
|
||||
{ t: 'cmd', c: ' mvn clean package' },
|
||||
{ t: 'ok', c: 'BUILD SUCCESS · 42s' },
|
||||
{ t: 'hl', c: '{ "status": "200 OK" }' },
|
||||
],
|
||||
glyph: 'text',
|
||||
gc: '</>',
|
||||
},
|
||||
notes: {
|
||||
title: 'scratchpad — gotchas',
|
||||
lines: [
|
||||
{ t: 'dim', c: '3-minute reads' },
|
||||
{ t: 'hl', c: 'fix · traefik forward-auth' },
|
||||
{ t: 'hl', c: 'fix · cv 301 → 404 chain' },
|
||||
{ t: 'dim', c: '→ lessons, fast' },
|
||||
],
|
||||
glyph: 'text',
|
||||
gc: '!',
|
||||
},
|
||||
tutorials: {
|
||||
title: 'learn — follow along',
|
||||
lines: [
|
||||
{ t: 'dim', c: 'copy · run · understand' },
|
||||
{ t: 'prompt', c: '$' },
|
||||
{ t: 'cmd', c: ' npm create astro@latest' },
|
||||
{ t: 'ok', c: 'step 01 · scaffolded' },
|
||||
{ t: 'ok', c: 'step 02 · deployed' },
|
||||
],
|
||||
glyph: 'text',
|
||||
gc: '>_',
|
||||
},
|
||||
web3: {
|
||||
title: 'foundry — testnet 31337',
|
||||
lines: [
|
||||
{ t: 'dim', c: 'solidity · honest scope' },
|
||||
{ t: 'prompt', c: '$' },
|
||||
{ t: 'cmd', c: ' forge test' },
|
||||
{ t: 'ok', c: '[PASS] testMintERC721' },
|
||||
{ t: 'ok', c: '[PASS] testTransferERC20' },
|
||||
],
|
||||
glyph: 'text',
|
||||
gc: '0x',
|
||||
},
|
||||
};
|
||||
|
||||
const fallback: (typeof ART)[string] = {
|
||||
title: slug,
|
||||
lines: [
|
||||
{ t: 'dim', c: 'mr hoelee · blog' },
|
||||
{ t: 'prompt', c: '$' },
|
||||
{ t: 'cmd', c: ' ls posts' },
|
||||
],
|
||||
glyph: 'text',
|
||||
gc: '#',
|
||||
};
|
||||
const art = ART[slug] ?? fallback;
|
||||
const accent = meta?.accent ?? '#295cff';
|
||||
|
||||
const LINE_COLORS: Record<Line['t'], string> = {
|
||||
prompt: accent,
|
||||
cmd: '#e6edf3',
|
||||
ok: '#3fb950',
|
||||
hl: '#f5a524',
|
||||
dim: '#6b7c99',
|
||||
};
|
||||
---
|
||||
|
||||
<svg
|
||||
viewBox="0 0 640 320"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label={meta ? `${meta.name.en} category` : 'Category illustration'}
|
||||
focusable="false"
|
||||
>
|
||||
<rect width="640" height="320" fill="#0d1220" />
|
||||
<circle cx="600" cy="-20" r="150" fill={accent} opacity="0.12" />
|
||||
<circle cx="10" cy="330" r="130" fill="#0e94ff" opacity="0.08" />
|
||||
|
||||
<!-- terminal window -->
|
||||
<rect x="28" y="24" width="584" height="268" rx="12" fill="#161e2e" stroke="#2a3550" stroke-width="1" />
|
||||
<!-- titlebar -->
|
||||
<path
|
||||
d="M 28 36 a 12 12 0 0 1 12 -12 h 560 a 12 12 0 0 1 12 12 v 32 h -584 z"
|
||||
fill="#131a2a"
|
||||
/>
|
||||
<circle cx="52" cy="46" r="6" fill="#ff5c5c" />
|
||||
<circle cx="74" cy="46" r="6" fill="#f5a524" />
|
||||
<circle cx="96" cy="46" r="6" fill="#3fb950" />
|
||||
<text x="118" y="52" fill="#6b7c99" font-family="'JetBrains Mono', ui-monospace, monospace" font-size="15">{art.title}</text>
|
||||
|
||||
<!-- body lines -->
|
||||
{art.lines.map((ln, i) => (
|
||||
<text
|
||||
x="52"
|
||||
y={112 + i * 36}
|
||||
fill={LINE_COLORS[ln.t]}
|
||||
font-family="'JetBrains Mono', ui-monospace, monospace"
|
||||
font-size="19"
|
||||
>{ln.c}</text>
|
||||
))}
|
||||
|
||||
<!-- watermark glyph -->
|
||||
{art.glyph === 'text' ? (
|
||||
<text
|
||||
x="588"
|
||||
y="262"
|
||||
text-anchor="end"
|
||||
fill={accent}
|
||||
opacity="0.16"
|
||||
font-family="'JetBrains Mono', ui-monospace, monospace"
|
||||
font-weight="700"
|
||||
font-size="96"
|
||||
>{art.gc}</text>
|
||||
) : (
|
||||
<g opacity="0.22">
|
||||
<circle cx="560" cy="196" r="58" fill="none" stroke={accent} stroke-width="10" />
|
||||
<path d="M 536 196 l 17 17 l 32 -36" fill="none" stroke={accent} stroke-width="12" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import CategoryArt from './CategoryArt.astro';
|
||||
import { ALL_CATEGORIES } from '../lib/categories';
|
||||
|
||||
interface Props {
|
||||
/** Per-slug post count for the current locale. */
|
||||
counts: Record<string, number>;
|
||||
lang?: 'en' | 'zh';
|
||||
}
|
||||
|
||||
const { counts, lang = 'en' } = Astro.props;
|
||||
const isZh = lang === 'zh';
|
||||
|
||||
const label = (count: number) => {
|
||||
if (isZh) return count === 0 ? '0 篇 · 敬请期待' : `${count} 篇`;
|
||||
return count === 0 ? '0 posts · coming soon' : `${count} post${count === 1 ? '' : 's'}`;
|
||||
};
|
||||
---
|
||||
|
||||
<ul class="category-grid">
|
||||
{ALL_CATEGORIES.map((cat) => {
|
||||
const count = counts[cat.slug] ?? 0;
|
||||
const href = count > 0 ? `/${isZh ? 'zh/' : ''}categories/${cat.slug}/` : null;
|
||||
const cardBodyAttrs = href ? { href } : {};
|
||||
const body = (
|
||||
<>
|
||||
<div class="art">
|
||||
<CategoryArt slug={cat.slug} />
|
||||
</div>
|
||||
<div class="body">
|
||||
<h2>
|
||||
<span class="dot" style={`background:${cat.accent}`} aria-hidden="true"></span>
|
||||
{cat.name[isZh ? 'zh' : 'en']}
|
||||
</h2>
|
||||
<p>{cat.blurb[isZh ? 'zh' : 'en']}</p>
|
||||
<span class="count" class:list={{ 'is-empty': count === 0 }}>
|
||||
<span class="n">{count}</span> {label(count)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li>
|
||||
{href ? (
|
||||
<a {...cardBodyAttrs} class="category-card">
|
||||
{body}
|
||||
</a>
|
||||
) : (
|
||||
<div class="category-card is-empty">{body}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
@@ -4,26 +4,51 @@ import type { CollectionEntry } from 'astro:content';
|
||||
interface Props {
|
||||
posts: CollectionEntry<'posts'>[];
|
||||
limit?: number;
|
||||
lang?: 'en' | 'zh';
|
||||
}
|
||||
|
||||
const { posts, limit } = Astro.props;
|
||||
const { posts, limit, lang = 'en' } = Astro.props;
|
||||
const shown = limit ? posts.slice(0, limit) : posts;
|
||||
const isZh = lang === 'zh';
|
||||
|
||||
const fmt = (d: Date) =>
|
||||
d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const updatedLabel = isZh ? '更新于' : 'Updated';
|
||||
|
||||
const catHref = (cat: string) => `/${isZh ? 'zh/categories' : 'categories'}/${cat}/`;
|
||||
|
||||
const og = (post: CollectionEntry<'posts'>) => post.data.ogImage || '/og-default.png';
|
||||
---
|
||||
|
||||
<ul class="post-list">
|
||||
{shown.map((post) => (
|
||||
<li>
|
||||
<a href={`/posts/${post.slug}/`}><h2>{post.data.title}</h2></a>
|
||||
<div class="meta">
|
||||
<time datetime={post.data.pubDate.toISOString()}>{fmt(post.data.pubDate)}</time>
|
||||
<span class="sep">·</span>
|
||||
<a class="tag" href={`/categories/${post.data.category}/`}>{post.data.category}</a>
|
||||
{post.data.tags.map((t) => <span class="tag">{t}</span>)}
|
||||
<li class="post-row">
|
||||
<a class="thumb" href={`/posts/${post.slug}/`} aria-hidden="true" tabindex="-1">
|
||||
<img src={og(post)} alt="" loading="lazy" width="1200" height="630" />
|
||||
</a>
|
||||
<div class="body">
|
||||
<a href={`/posts/${post.slug}/`}><h2>{post.data.title}</h2></a>
|
||||
<div class="meta">
|
||||
<time datetime={post.data.pubDate.toISOString()}>{fmt(post.data.pubDate)}</time>
|
||||
{post.data.updatedDate && (
|
||||
<>
|
||||
<span class="sep">·</span>
|
||||
<span class="updated" title={`${updatedLabel} ${fmt(post.data.updatedDate)}`}>
|
||||
{updatedLabel} {fmt(post.data.updatedDate)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span class="sep">·</span>
|
||||
<a class="tag" href={catHref(post.data.category)}>{post.data.category}</a>
|
||||
{post.data.tags.map((t) => <span class="tag">{t}</span>)}
|
||||
</div>
|
||||
<p>{post.data.description}</p>
|
||||
</div>
|
||||
<p>{post.data.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul>
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
// Client-side search via Pagefind. The search box lives in the site header;
|
||||
// this component is mounted once per page (in BaseLayout) and drives the
|
||||
// overlay + results. Pagefind's index is generated post-build (see the build
|
||||
// script); its UI/JS loads from /pagefind/ at runtime.
|
||||
interface Props {
|
||||
lang: string;
|
||||
}
|
||||
const { lang } = Astro.props;
|
||||
const placeholder =
|
||||
lang === 'zh' ? '搜索文章…' : 'Search posts…';
|
||||
const noResults =
|
||||
lang === 'zh' ? '没有匹配的结果。' : 'No results.';
|
||||
---
|
||||
|
||||
<div class="search-overlay" id="search-overlay" hidden>
|
||||
<div class="search-panel">
|
||||
<div class="search-bar">
|
||||
<input
|
||||
type="search"
|
||||
id="search-input"
|
||||
placeholder={placeholder}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
<button class="search-close" id="search-close" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="search-results" id="search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: color-mix(in srgb, var(--paper) 92%, transparent);
|
||||
backdrop-filter: blur(4px);
|
||||
padding-top: 4rem;
|
||||
}
|
||||
.search-panel {
|
||||
max-width: var(--measure);
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.05rem;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
.search-close {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
border-radius: 6px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.search-close:hover { color: var(--brand); border-color: var(--brand); }
|
||||
.search-results {
|
||||
margin-top: 1rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.search-result {
|
||||
display: block;
|
||||
padding: 0.85rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.search-result:hover { color: var(--brand); }
|
||||
.search-result h3 { margin: 0 0 0.2rem; font-size: 1.05rem; }
|
||||
.search-result .excerpt { color: var(--muted); font-size: 0.88rem; margin: 0; }
|
||||
.search-result mark {
|
||||
background: color-mix(in srgb, var(--brand) 25%, transparent);
|
||||
color: var(--ink);
|
||||
border-radius: 2px;
|
||||
padding: 0 0.1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
interface Pagefind {
|
||||
search(query: string): Promise<{ results: PagefindResult[] }>;
|
||||
debouncedSearch(query: string): Promise<{ results: PagefindResult[] }>;
|
||||
}
|
||||
interface PagefindResult {
|
||||
data(): Promise<{ url: string; meta: Record<string, string>; excerpt: string }>;
|
||||
}
|
||||
|
||||
const overlay = document.getElementById('search-overlay') as HTMLElement;
|
||||
const input = document.getElementById('search-input') as HTMLInputElement;
|
||||
const results = document.getElementById('search-results') as HTMLElement;
|
||||
const closeBtn = document.getElementById('search-close') as HTMLButtonElement;
|
||||
|
||||
let pagefind: Pagefind | undefined;
|
||||
let searchButton: HTMLElement | null = null;
|
||||
|
||||
function open() {
|
||||
overlay.hidden = false;
|
||||
input.value = '';
|
||||
results.innerHTML = '';
|
||||
input.focus();
|
||||
}
|
||||
function close() {
|
||||
overlay.hidden = true;
|
||||
}
|
||||
|
||||
// The header search trigger is injected by BaseLayout's script.
|
||||
const trigger = document.getElementById('search-toggle');
|
||||
if (trigger) trigger.addEventListener('click', open);
|
||||
closeBtn.addEventListener('click', close);
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !overlay.hidden) close();
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
overlay.hidden ? open() : close();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadPagefind() {
|
||||
if (pagefind) return pagefind;
|
||||
// Pagefind's UI/JS is copied into /pagefind/ post-build. Load it via a
|
||||
// script tag (not a dynamic import) so Vite leaves the URL untouched.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = '/pagefind/pagefind.js';
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error('pagefind load failed'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
const w = window as unknown as { Pagefind?: Pagefind };
|
||||
pagefind = w.Pagefind;
|
||||
return pagefind;
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim();
|
||||
if (!q) {
|
||||
results.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const pf = await loadPagefind();
|
||||
const search = pf.debouncedSearch ? pf.debouncedSearch(q) : pf.search(q);
|
||||
const res = await search;
|
||||
results.innerHTML = '';
|
||||
if (!res.results.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'excerpt';
|
||||
empty.textContent = 'No results.';
|
||||
results.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const r of res.results.slice(0, 12)) {
|
||||
const d = await r.data();
|
||||
const a = document.createElement('a');
|
||||
a.className = 'search-result';
|
||||
a.href = d.url;
|
||||
const h = document.createElement('h3');
|
||||
h.textContent = d.meta.title || d.url;
|
||||
const ex = document.createElement('p');
|
||||
ex.className = 'excerpt';
|
||||
ex.innerHTML = d.excerpt || '';
|
||||
a.appendChild(h);
|
||||
a.appendChild(ex);
|
||||
results.appendChild(a);
|
||||
}
|
||||
} catch (err) {
|
||||
results.innerHTML = '';
|
||||
const errEl = document.createElement('p');
|
||||
errEl.className = 'excerpt';
|
||||
errEl.textContent = 'Search is unavailable.';
|
||||
results.appendChild(errEl);
|
||||
}
|
||||
}, 120);
|
||||
});
|
||||
</script>
|
||||
@@ -21,7 +21,11 @@ const posts = defineCollection({
|
||||
// lang is NOT set in frontmatter anymore — it's derived from the folder:
|
||||
// posts/hello-world.md -> en (flat)
|
||||
// posts/zh/hello-world.md -> zh (subfolder)
|
||||
// Explicit link to the other-language version (its full slug, e.g. "zh/how-i-host-this-blog").
|
||||
// When absent, the language switch falls back to the section landing page (/ or /zh/).
|
||||
translation: z.string().optional(),
|
||||
ogImage: z.string().optional(),
|
||||
banner: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
---
|
||||
title: "How I Built an AI Furniture Compositing Demo With FLUX.1 Kontext"
|
||||
description: "Compositing furniture photos into styled room scenes with fal.ai FLUX.1 Kontext — the multi-image endpoint, the tainted-canvas fix, and why prompt enhancement had to go."
|
||||
pubDate: 2026-09-11
|
||||
category: case-studies
|
||||
tags: [fal-ai, flux, ai-image, javascript, canvas, ecommerce]
|
||||
ogImage: /og/ai-furniture-compositing-with-flux-kontext.png
|
||||
banner: /banners/ai-furniture-compositing-with-flux-kontext.png
|
||||
---
|
||||
|
||||
A furniture client came to me with a problem that every small e-commerce seller
|
||||
eventually hits: their catalogue photos are **isolated product shots** — a
|
||||
table on white, a chair on white — but customers don't buy furniture from a
|
||||
white void. They buy the *room*. Staging a real photoshoot for every product,
|
||||
in every interior style, is out of the question for a one-person business.
|
||||
|
||||
The ask: take two product photos (a dining table, a chair) and place them
|
||||
together into a believable, high-end interior scene — generated, not
|
||||
photographed. This post is the story of the proof-of-concept I built to prove
|
||||
that's possible, the model I chose, and the three bugs that tried to eat it.
|
||||
|
||||
## Why FLUX.1 Kontext — and the endpoint that matters
|
||||
|
||||
The obvious first instinct is a general text-to-image model: *"a dining table
|
||||
and a chair in a modern living room."* That fails the moment the client says
|
||||
*"no — MY table, MY chair, the exact one on my product page."* Generating a
|
||||
lookalike product is worthless; the whole point is to keep the **real product
|
||||
unchanged** and only change the room around it.
|
||||
|
||||
That's the specific problem **FLUX.1 Kontext** (via [fal.ai](https://fal.ai))
|
||||
is built for: image-conditioned generation, where the reference image *anchors*
|
||||
the product and the prompt describes the scene. But there's a subtlety that cost
|
||||
me an afternoon: there are **two** endpoints.
|
||||
|
||||
| Endpoint | Reference input | Use case |
|
||||
|---|---|---|
|
||||
| `fal-ai/flux-pro/kontext` | `image_url` (single) | edit / re-context one image |
|
||||
| `fal-ai/flux-pro/kontext/multi` | `image_urls` (array) | **combine multiple reference images** |
|
||||
|
||||
I needed to *fuse two separate products into one scene*, so plain `kontext`
|
||||
wasn't enough — it only takes one reference image. The `multi` variant accepts
|
||||
an array of reference images and is what makes "table + chair → one room"
|
||||
possible. It's flagged experimental, but it's the only game in town for this.
|
||||
|
||||
## Calling fal.ai from a plain HTML file — no SDK, no server
|
||||
|
||||
For a proof-of-concept I didn't want to stand up a backend. fal.ai exposes a
|
||||
plain REST queue API, so a single `index.html` with vanilla JavaScript can do
|
||||
the whole job. There are three steps, not one:
|
||||
|
||||
```js
|
||||
// 1. Submit — returns request_id + polling URLs
|
||||
const submit = await fetch(
|
||||
"https://queue.fal.run/fal-ai/flux-pro/kontext/multi",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Key " + FAL_KEY,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: "...",
|
||||
image_urls: [tableDataUrl, chairDataUrl],
|
||||
enhance_prompt: false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const { request_id, status_url, response_url } = await submit.json();
|
||||
|
||||
// 2. Poll status until COMPLETED (takes ~5–30s)
|
||||
let status;
|
||||
do {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
status = (await (await fetch(status_url, {
|
||||
headers: { Authorization: "Key " + FAL_KEY },
|
||||
})).json()).status;
|
||||
} while (status === "IN_QUEUE" || status === "IN_PROGRESS");
|
||||
|
||||
// 3. Fetch the result
|
||||
const result = await (await fetch(response_url, {
|
||||
headers: { Authorization: "Key " + FAL_KEY },
|
||||
})).json();
|
||||
// result.images[0].url is the generated image
|
||||
```
|
||||
|
||||
Two things I verified early, because they make or break the "single file"
|
||||
approach:
|
||||
|
||||
1. **CORS is open.** `queue.fal.run` returns a permissive
|
||||
`access-control-allow-origin` and allows the `authorization` header, so a
|
||||
browser can call it directly with no proxy. I confirmed this with a
|
||||
preflight before writing any UI.
|
||||
2. **Local images go in as base64 data URIs.** fal.ai accepts `data:` URIs in
|
||||
`image_urls`, so I never had to upload the client's photos to a storage
|
||||
bucket first — the demo reads a file, compresses it, and ships it straight
|
||||
in the request body.
|
||||
|
||||
## The canvas compression layer
|
||||
|
||||
Phone photos are 5–10 MB. Two of those, base64-encoded, bloats the request to
|
||||
unusable size and slows generation. So before submitting, the demo runs each
|
||||
image through a `<canvas>` to resize and re-encode it:
|
||||
|
||||
```js
|
||||
async function imgToDataURI(file, maxSize = 1024, quality = 0.85) {
|
||||
const img = await createImageBitmap(file); // or new Image()
|
||||
const scale = Math.min(1, maxSize / Math.max(img.width, img.height));
|
||||
const c = document.createElement("canvas");
|
||||
c.width = Math.round(img.width * scale);
|
||||
c.height = Math.round(img.height * scale);
|
||||
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
|
||||
return c.toDataURL("image/jpeg", quality); // ~200–400 KB each
|
||||
}
|
||||
```
|
||||
|
||||
A 7 MB phone shot becomes a ~300 KB JPEG. Fast to upload, fast for the model to
|
||||
process.
|
||||
|
||||
## Bug #1 — "Tainted canvases may not be exported"
|
||||
|
||||
The demo worked in my head. Then it didn't work in a browser. The moment I hit
|
||||
"generate" I got:
|
||||
|
||||
> `Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.`
|
||||
|
||||
This is a browser security boundary, not a fal.ai problem. When you open the
|
||||
page over `file://` and load a *local* image with `<img src="chair.jpg">`,
|
||||
that image is an **opaque origin**. The moment it's drawn onto a canvas, the
|
||||
canvas becomes *tainted*, and `toDataURL()` refuses to export it — the browser
|
||||
won't let a webpage read back the pixels of a file it can't prove it's allowed
|
||||
to read.
|
||||
|
||||
Two fixes, and I used both at different points:
|
||||
|
||||
1. **Inline the default images as base64 data URIs** directly in the HTML
|
||||
source. Data URIs don't taint the canvas, so the demo works even when
|
||||
double-clicked from disk.
|
||||
2. **Serve over HTTP with same-origin relative paths** (`src="chair.jpg"`).
|
||||
Once the page and images share an origin, the canvas stays clean. This is
|
||||
the right answer for the real deployment — the demo now lives at a URL, not
|
||||
a double-clicked file.
|
||||
|
||||
The general rule: **`toDataURL` fails the instant any opaque-origin image touches
|
||||
the canvas.** If you're loading local files, either inline them or serve over
|
||||
HTTP. There is no third way without changing browser security settings.
|
||||
|
||||
## Bug #2 — the API key is in the HTML
|
||||
|
||||
For a demo this is tolerable; for anything public it's a hole. A `Key` header in
|
||||
a client-side `fetch` means the key is in the page source, readable by anyone
|
||||
who presses F12. Fine for a PoC I hand to a client, unacceptable for production.
|
||||
|
||||
The plan for the real WordPress plugin (the next phase) is to move the key to
|
||||
**server-side PHP**: the plugin's endpoint does the authenticated fal.ai call
|
||||
and returns the image, while the browser only talks to the plugin's own route.
|
||||
The demo proved the pipeline works; the plugin will put the secret where
|
||||
secrets belong.
|
||||
|
||||
## Bug #3 — the output looked like "the second image"
|
||||
|
||||
This was the one that made me question whether `multi` even worked. I swapped in
|
||||
a new table photo, generated, and the result looked almost identical to the
|
||||
chair reference — as if the model had just ignored the table and copied the
|
||||
chair.
|
||||
|
||||
Before blaming the model, I checked something falsifiable: **is `multi` even
|
||||
compositing, or just echoing one input?** I compared the generated image
|
||||
against both source images with a perceptual hash and mean pixel difference:
|
||||
|
||||
| Comparison | Mean pixel diff (0 = identical) | dHash distance |
|
||||
|---|---|---|
|
||||
| result vs table | 55.7 | 31 |
|
||||
| result vs chair | 55.3 | 29 |
|
||||
| table vs chair | 69.3 | 30 |
|
||||
|
||||
The result was *far* from both inputs — the endpoint genuinely composites a new
|
||||
scene, not a copy. So the "looks like the chair" problem wasn't the API; it was
|
||||
the **inputs and the prompt**.
|
||||
|
||||
Three compounding causes:
|
||||
|
||||
1. **The reference photos were inconsistent.** The original `chair.jpg` was a
|
||||
photo of a full table-and-chair *scene*, not a clean single chair — so the
|
||||
model already saw "table + chair" satisfied by one image and leaned on it.
|
||||
2. **Positional prompting is unreliable.** Kontext matches images by *content*,
|
||||
not array order. Saying "the first image" / "the second image" doesn't
|
||||
reliably map to "the table" / "the chair". The fix is to name the objects —
|
||||
*"the dining table"* and *"the chair"* — and let the model match them to the
|
||||
right reference.
|
||||
3. **Prompt enhancement was on.** fal.ai's `enhance_prompt` (default off, but I
|
||||
had it in mind) rewrites your prompt into a richer aesthetic description —
|
||||
exactly wrong when the goal is *"do not reinterpret the product."* I pinned
|
||||
it to `false` so the model obeys the literal prompt instead of embellishing
|
||||
it.
|
||||
|
||||
## The prompt that finally worked
|
||||
|
||||
The client's core requirement was strict: **preserve the furniture exactly** and
|
||||
only invent the room. That needs a prompt that spends most of its length
|
||||
*preventing* reinterpretation, not describing style:
|
||||
|
||||
> Create a photorealistic interior photograph using the exact dining table and
|
||||
> exact chair from the reference images. Preserve both furniture pieces exactly
|
||||
> as shown — do not redesign, recolor, repaint, restyle, replace, or reinterpret
|
||||
> either product. Keep their original geometry, proportions, construction,
|
||||
> material, finish, texture, and color exactly unchanged.
|
||||
>
|
||||
> Place the chair naturally beside the dining table in a realistic dining
|
||||
> position, slightly pulled under the table and properly aligned with it.
|
||||
> Maintain realistic scale, perspective, and physical contact with the floor.
|
||||
> The chair and table must look photographed together in the same real room,
|
||||
> with natural contact shadows — not composited or pasted together.
|
||||
>
|
||||
> Preserve the true original color. Do not allow the room lighting, wall or
|
||||
> floor colors, or grading to alter the furniture.
|
||||
>
|
||||
> Set the scene in a spacious modern living room with floor-to-ceiling windows
|
||||
> and subtle warm afternoon daylight. Use restrained, neutral interior colors.
|
||||
>
|
||||
> Photorealistic commercial furniture photography, realistic camera
|
||||
> perspective, natural proportions, high detail.
|
||||
|
||||
The demo ships six of these, one per scene style (modern living room, cozy
|
||||
dining room, Scandinavian, wabi-sabi, and a clean studio), each differing only
|
||||
in the "set the scene" paragraph.
|
||||
|
||||
## The result
|
||||
|
||||
One `index.html`, no backend, no SDK — two product photos in, a staged room
|
||||
out, at **$0.04 per image** and roughly 15–20 seconds of generation. The client
|
||||
now has a working preview they can show *their* customers, with a scene selector
|
||||
and an editable prompt, before committing to the full WordPress plugin.
|
||||
|
||||
The proof-of-concept answered the question it was built to answer: **yes, you
|
||||
can keep the real product and only generate the room around it** — as long as
|
||||
you respect the three constraints that actually matter: a clean reference image
|
||||
per product, object-named (not positional) prompts, and `enhance_prompt: false`.
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
- **Require clean, single-product reference images on day one.** Every failure
|
||||
mode downstream — the "looks like one image" problem, the proportion drift —
|
||||
traces back to inconsistent source photos. I'd ship a tiny client-facing
|
||||
guide ("one product per photo, plain background, no other furniture") before
|
||||
touching any model.
|
||||
- **Turn `enhance_prompt` off explicitly, first.** I lost a generation to the
|
||||
enhancement rewriting my careful "do not reinterpret" instructions into
|
||||
exactly the opposite.
|
||||
- **Validate CORS with a real preflight before building UI.** It's a two-line
|
||||
`curl` and it de-risks the entire architecture decision.
|
||||
|
||||
---
|
||||
|
||||
## Want this for your product catalogue?
|
||||
|
||||
I build AI image pipelines, WordPress plugins, and self-hosted infrastructure
|
||||
for e-commerce businesses. If you want to stage your products in styled room
|
||||
scenes — or hire me for a similar AI integration — I'd love to talk:
|
||||
|
||||
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
|
||||
- 📧 **Email:** [[email protected]](mailto:[email protected]?subject=AI%20product%20image%20compositing)
|
||||
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
title: "The Character That Silently Broke My authentik CSS"
|
||||
description: "My authentik custom CSS looked correct, matched the right elements, and did nothing. The cause was a single > character that authentik escapes into invalid text."
|
||||
pubDate: 2026-09-20
|
||||
category: devops
|
||||
tags: ["authentik", "css", "self-hosting", "debugging", "browser"]
|
||||
ogImage: /og/authentik-css-greater-than-bug.png
|
||||
banner: /banners/authentik-css-greater-than-bug.png
|
||||
draft: true
|
||||
---
|
||||
|
||||
I spent an afternoon on a CSS rule that should have taken thirty seconds.
|
||||
|
||||
I wanted to hide one line in the authentik login page footer — the
|
||||
hardcoded "Powered by authentik" credit. The rule I wrote has worked in
|
||||
every other project I've touched:
|
||||
|
||||
```css
|
||||
ul.pf-c-list > li:last-child {
|
||||
display: none !important;
|
||||
}
|
||||
```
|
||||
|
||||
It did nothing. Not "it looked slightly off" — the element stayed
|
||||
fully visible. What follows is the four wrong answers I chased, the one
|
||||
correct answer, and the debugging move I should have made first.
|
||||
|
||||
## Why this matters beyond one footer line
|
||||
|
||||
If you self-host authentik and have ever pasted CSS into
|
||||
**System → Brands → Custom CSS** and seen zero effect, you have probably
|
||||
concluded you did something wrong. You almost certainly didn't. The
|
||||
stylesheet is accepted, stored, served to the browser, and parsed — and
|
||||
then silently fails, with no error in any log you can reach.
|
||||
|
||||
That is the worst kind of bug: no feedback loop. This post gives you the
|
||||
loop back.
|
||||
|
||||
## Wrong answer #1: it's shadow DOM, so CSS can't reach it
|
||||
|
||||
My first assumption. Modern web components often hide their markup
|
||||
behind a shadow root, and normal document CSS cannot cross that
|
||||
boundary. The authentik login page is rendered by web components — I had
|
||||
seen `<ak-flow-executor>` and `<ak-drawer>` in the page source — so this
|
||||
felt obviously right.
|
||||
|
||||
I read the component definition out of the shipped bundle to confirm:
|
||||
|
||||
```js
|
||||
var oe = class extends L {
|
||||
createRenderRoot() { return this }
|
||||
render() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
`createRenderRoot(){ return this }` means **no shadow root** — the
|
||||
component renders into the light DOM. Ordinary CSS reaches it just fine.
|
||||
|
||||
Wrong answer. Moving on.
|
||||
|
||||
## Wrong answer #2: the CSS isn't being injected at all
|
||||
|
||||
Next theory: my CSS never made it into the page. I grepped the served
|
||||
HTML for a distinctive class from my rule:
|
||||
|
||||
```
|
||||
<style data-id="brand-css">.ak-login-container{ padding-top: 16vh; ... }
|
||||
```
|
||||
|
||||
It was there, first try. authentik injects brand CSS as a `<style
|
||||
data-id="brand-css">` block in the document head. Injection was working.
|
||||
|
||||
Wrong answer.
|
||||
|
||||
## Wrong answer #3: specificity — PatternFly is winning
|
||||
|
||||
Plausible. authentik's UI is built on PatternFly, which ships opinionated
|
||||
list styles. My rule used `!important`, but `!important` only wins within
|
||||
the same cascade layer — and if PatternFly's rule were also `!important`
|
||||
in a later layer, mine would lose.
|
||||
|
||||
This is where I stopped guessing and started measuring. I loaded the page
|
||||
in a headless browser and asked the DOM directly:
|
||||
|
||||
```js
|
||||
const host = document.querySelector('ak-brand-links');
|
||||
const li = host.querySelector('li[data-kind="text"]');
|
||||
return {
|
||||
display: getComputedStyle(li).display,
|
||||
matchesDataKind: li.matches('ul.pf-c-list > li[data-kind="text"]'),
|
||||
matchesLastChild: li.matches('ul.pf-c-list.pf-m-inline > li:last-child'),
|
||||
};
|
||||
```
|
||||
|
||||
The answer:
|
||||
|
||||
```
|
||||
display: "list-item" ← not hidden
|
||||
matchesDataKind: true ← my selector IS correct
|
||||
matchesLastChild: true ← and so is this one
|
||||
```
|
||||
|
||||
The selectors matched the element. The CSS still didn't apply. That
|
||||
combination is only possible if the stylesheet the browser parsed no
|
||||
longer contains the rule I wrote.
|
||||
|
||||
## Wrong answer #4: (there wasn't one — I read the parsed CSS)
|
||||
|
||||
So I read back what the browser's **CSS parser** had actually
|
||||
registered, not what the page source said:
|
||||
|
||||
```js
|
||||
for (const sheet of document.styleSheets) {
|
||||
if (sheet.ownerNode.getAttribute('data-id') === 'brand-css') {
|
||||
for (const rule of sheet.cssRules) console.log(rule.cssText);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
"ul.pf-c-list u003e li[data-kind=\"text\"], ul.pf-c-list.pf-m-inline u003e li:last-child { display: none !important; }"
|
||||
```
|
||||
|
||||
There it is. **`u003e`.**
|
||||
|
||||
The `>` character — written correctly in my CSS, stored correctly in the
|
||||
database, returned correctly by the API — was rendered into the HTML as
|
||||
the literal text `u003e`. Not the `>` character. The six characters
|
||||
`u`, `0`, `0`, `3`, `e`.
|
||||
|
||||
So the browser tried to parse this selector:
|
||||
|
||||
```
|
||||
ul.pf-c-list u003e li[data-kind="text"]
|
||||
```
|
||||
|
||||
`u003e` is not a combinator. The selector is invalid. An invalid selector
|
||||
in a comma-separated list is discarded, so the rule never existed as far
|
||||
as the browser was concerned — while `matches()` on the *correct*
|
||||
selector string still returned `true`, which is why the element looked
|
||||
like it matched something.
|
||||
|
||||
## The fix
|
||||
|
||||
Remove every child combinator from authentik brand CSS. Use a descendant
|
||||
selector instead — a single space instead of `>`:
|
||||
|
||||
```css
|
||||
/* BROKEN — the > becomes literal text "u003e" and the
|
||||
selector is discarded */
|
||||
ul.pf-c-list > li[data-kind="text"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* WORKS — descendant combinator passes through intact */
|
||||
ul.pf-c-list li[data-kind="text"] {
|
||||
display: none !important;
|
||||
}
|
||||
```
|
||||
|
||||
That is the whole fix. One character deleted.
|
||||
|
||||
### Where the escaping comes from
|
||||
|
||||
`\u003E` is how JSON/JavaScript encodes `>`. authentik's Django templates
|
||||
render the brand config as a JavaScript object literal, and a HTML/JS
|
||||
escaper is being applied to the `branding_custom_css` string. Backslash
|
||||
and the `u` get separated somewhere in that path, so the browser receives
|
||||
`u003e` — the escape without its backslash — instead of `>`.
|
||||
|
||||
The stored value is correct. The API response is correct. Only the
|
||||
rendered page is wrong:
|
||||
|
||||
```bash
|
||||
# What the API returns — correct
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
"https://auth.hoelee.com/api/v3/core/brands/" \
|
||||
| python -c "import sys,json;print([b['branding_custom_css'] for b in json.load(sys.stdin)['results']][0])"
|
||||
# → ul.pf-c-list > li[data-kind="text"] { ... } ← > is intact here
|
||||
|
||||
# What the browser receives — corrupted
|
||||
curl -sL https://auth.hoelee.com/ | grep -o 'ul.pf-c-list[^;]*'
|
||||
# → ul.pf-c-list \u003E li[data-kind="text"] ← escaped
|
||||
```
|
||||
|
||||
This is why the bug is nearly unreachable by searching: anyone debugging
|
||||
via the API, the database, or the admin UI sees a perfectly correct
|
||||
stylesheet.
|
||||
|
||||
### What survived and what didn't
|
||||
|
||||
I probed which characters get mangled, to know what else to avoid:
|
||||
|
||||
| Character | Renders as | Safe? |
|
||||
|---|---|---|
|
||||
| `>` | `u003e` | ❌ breaks the selector |
|
||||
| `;` | `;` | ✅ |
|
||||
| `"` | `"` | ✅ |
|
||||
| `{` `}` `:` | unchanged | ✅ |
|
||||
|
||||
Only the child combinator is affected in practice, because `>` is the
|
||||
only one of these that appears in a CSS selector — the others only appear
|
||||
in declarations, which are preserved.
|
||||
|
||||
## Verifying the fix in a real browser
|
||||
|
||||
Do not verify by re-reading the page source — that was the trap. Ask the
|
||||
browser for the element's geometry:
|
||||
|
||||
```js
|
||||
const li = document.querySelector('li[data-kind="text"]');
|
||||
return {
|
||||
display: getComputedStyle(li).display,
|
||||
visible: li.getBoundingClientRect().height > 0,
|
||||
};
|
||||
```
|
||||
|
||||
```
|
||||
display: "none"
|
||||
visible: false
|
||||
```
|
||||
|
||||
The element is gone. That is a real measurement, not an inference.
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
**When a selector matches but the styles don't apply, read
|
||||
`styleSheets[i].cssRules` immediately.** Everything before that step was
|
||||
speculation I could have skipped. The parsed rule list is the
|
||||
browser's ground truth — it tells you in one line whether the rule you
|
||||
*wrote* is the rule that *exists*.
|
||||
|
||||
The specific ordering I'd use next time:
|
||||
|
||||
1. Does the element exist and match? → `element.matches(selector)`
|
||||
2. Is the rule present in the parsed stylesheet? → `cssRules`
|
||||
3. Only then consider specificity, layers, and `!important`
|
||||
|
||||
I did those in the opposite order, which is why it took an afternoon.
|
||||
|
||||
The second lesson is narrower but worth writing down: **escaping bugs
|
||||
live between the layer that stores data and the layer that renders it.**
|
||||
Check the value at both ends before you check anything else. I looked at
|
||||
the database, then at the API, and concluded the CSS was fine. The bug
|
||||
was in the third place I looked.
|
||||
|
||||
## The result
|
||||
|
||||
One character deleted, one footer line hidden, and a debugging rule
|
||||
that has already paid for itself: in the same session it took me three
|
||||
minutes to find a similar mismatch in a different rule, because I went
|
||||
straight to `cssRules` instead of guessing.
|
||||
|
||||
If you self-host authentik and your brand CSS has ever silently done
|
||||
nothing — check for a `>` first.
|
||||
|
||||
---
|
||||
|
||||
## Want this for your business?
|
||||
|
||||
If you need self-hosted SSO, a hardened login page, or someone to debug
|
||||
the infrastructure you already run, that's the work I do.
|
||||
|
||||
- **WhatsApp:** [011-797 2969](https://wa.me/60127972969) — tap to chat
|
||||
- **Email:** [[email protected]](mailto:[email protected]?subject=Self-hosted%20SSO%20enquiry)
|
||||
- **Website:** [hoelee.com](https://www.hoelee.com)
|
||||
|
||||
I set up authentik single sign-on, self-hosted Docker stacks, reverse
|
||||
proxies and tunnels for small businesses in Malaysia — and I write up
|
||||
what I learn while doing it.
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
title: "Authentik 2025.8 to 2026.8: The Breakable Parts Nobody Warns You About"
|
||||
description: "A year's worth of authentik major-version upgrade pain: storage mount changes, RBAC session cleanup, trusted proxies, and the authorization_flow vs authentication_flow mix-up that broke SSO."
|
||||
pubDate: 2026-09-09
|
||||
category: devops
|
||||
tags: [authentik, sso, docker, portainer, self-hosting, oidc, upgrade]
|
||||
ogImage: /og/authentik-major-upgrade-gotchas.png
|
||||
banner: /banners/authentik-major-upgrade-gotchas.png
|
||||
---
|
||||
|
||||
I run authentik as the single sign-on gate in front of my self-hosted stack —
|
||||
email, dashboard, Synology apps, a remote-access outpost. For a long while it
|
||||
sat on **2025.8.3**, and a year of releases piled up. This is the story of
|
||||
bringing it all the way to **2026.8.1** in one sitting, and everything that
|
||||
broke along the way — especially the one mistake that took Single Sign-On
|
||||
completely offline and made every internal app ask for a password again.
|
||||
|
||||
## What is authentik, and why run it?
|
||||
|
||||
authentik is an open-source **identity provider (IdP)** — the thing that answers
|
||||
one question over and over: *"who is this person, and are they allowed in?"*
|
||||
Think of it as the front door to a whole building of apps. Instead of every
|
||||
app running its own username/password screen (and its own list of bugs, its own
|
||||
"forgot password" flow, its own 2FA), you make all of them ask authentik instead.
|
||||
|
||||
It speaks the protocols that matter for real deployments:
|
||||
|
||||
- **OIDC / OAuth2** — the modern single sign-on standard (what "Sign in with
|
||||
Google" uses under the hood).
|
||||
- **SAML** — the enterprise standard (for tools like Grafana, Jira, or any
|
||||
legacy line-of-business app).
|
||||
- **LDAP** — so older clients and NAS boxes that only know LDAP can join too.
|
||||
- **Proxy / forward-auth** — it sits in front of an app via a reverse proxy and
|
||||
gatekeeps access before a request ever reaches the app.
|
||||
|
||||
The practical benefits, in order of how much they actually matter:
|
||||
|
||||
1. **One login for everything.** Log in once, move freely between every app.
|
||||
Users stop juggling a dozen passwords (and the support tickets that go with
|
||||
them).
|
||||
2. **One place to lock things down.** MFA, password policy, session limits, and
|
||||
account recovery live in authentik once — not re-implemented per app.
|
||||
3. **One place to audit.** Every login, on every app, for every user, in one
|
||||
log. That's the difference between "we think nothing happened" and "we can
|
||||
prove it" when a question comes up.
|
||||
4. **Self-contained and self-hosted.** You own the data. No per-user SaaS fees
|
||||
that scale with your headcount, no lock-in — it's AGPL, runs in Docker.
|
||||
|
||||
For a small business or a solo operator running twenty-something services
|
||||
(email, dashboards, NAS apps, internal tools), authentik is the difference
|
||||
between "every app has its own flimsy password" and "one hardened front door."
|
||||
|
||||
## Why upgrade at all
|
||||
|
||||
Version 2025.8.3 wasn't broken. But it had fallen far enough behind that a
|
||||
stack of CVEs had landed in the releases after it, and I was getting ready to
|
||||
do per-application branding. authentik's own policy is that you can't jump
|
||||
major versions — it enforces a stepwise path. So the plan was:
|
||||
|
||||
```
|
||||
2025.8.3 → 2025.10 → 2025.12 → 2026.2 → 2026.5 → 2026.8
|
||||
```
|
||||
|
||||
Six hops, one at a time, with a migration and a health-check between each.
|
||||
Before touching anything, the one non-negotiable step: **back up the database**.
|
||||
authentik doesn't support downgrades. If a migration half-runs, you're restoring
|
||||
from dump, not rolling back an image tag.
|
||||
|
||||
```bash
|
||||
sudo docker exec authentik-postgres pg_dump -U authentik authentik > authentik-backup.sql
|
||||
```
|
||||
|
||||
## Pitfall 1: Portainer is the source of truth, not the compose file
|
||||
|
||||
My first instinct was to edit the `docker-compose.yml` on disk and `up` it.
|
||||
Wrong. The stack is managed by **Portainer** (stack 143), which keeps the real
|
||||
compose and the real environment variables in its own store. The `.env` on disk
|
||||
was stale — its `PG_PASS` didn't match what Portainer actually ran.
|
||||
|
||||
The correct update path is via the Portainer API, not the filesystem:
|
||||
|
||||
1. Update the image tags in the compose content.
|
||||
2. `docker pull` the new images *first* (so the API call doesn't time out mid-pull).
|
||||
3. `docker stop` + `docker rm` the running containers (fixed `container_name`
|
||||
will otherwise collide on redeploy).
|
||||
4. `PUT /api/stacks/143?endpointId=2` with the new `StackFileContent` + `Env`.
|
||||
|
||||
I hit the colliding-container error, the pull-timeout error, and a network-attach
|
||||
problem where the rebuilt `authentik-server` only joined one of its two networks
|
||||
and couldn't resolve `postgres-server`. Each one is a five-minute fix once you
|
||||
know what you're looking at, but together they ate the better part of the evening.
|
||||
|
||||
## Pitfall 2: the storage mount moved (2025.12)
|
||||
|
||||
Up to 2025.12, brand assets lived under `/media/` and were served at `/media/...`.
|
||||
After 2025.12, the storage layout changed: files moved to a `/data/media`
|
||||
structure served under a new `/files/media/public/<name>?token=...` URL with a
|
||||
JWT signature. My containers were still mounting `./media:/media`, so every
|
||||
logo, favicon, and background image 404'd the moment I crossed that version.
|
||||
|
||||
The fix is the documented migration:
|
||||
|
||||
```bash
|
||||
mkdir -p data && mv media data/media
|
||||
```
|
||||
|
||||
…and change the mounts to `./data:/data`. The new file backend also refuses to
|
||||
work unless `/data` is an actual mount point — the earlier version of my fix
|
||||
used a symlink, which the backend's `is_mount()` check rejected with
|
||||
`No file management backend configured`.
|
||||
|
||||
## Pitfall 3: RBAC migration leaves a poisoned session table
|
||||
|
||||
2025.12 removed the old `authentik_core.User_groups` model in favor of the RBAC
|
||||
rework. The migration ran clean, but **old sessions** still held references to
|
||||
the deleted model. Result: the login page kept throwing
|
||||
`LookupError: App 'authentik_core' doesn't have a 'User_groups' model`.
|
||||
|
||||
Not `django_session` — that was empty. The real culprit was authentik's own
|
||||
`authentik_core_session` table. Clearing it (and the other session tables)
|
||||
forced everyone to log in again and cleared the error:
|
||||
|
||||
```sql
|
||||
TRUNCATE authentik_core_session;
|
||||
```
|
||||
|
||||
One side effect worth knowing: this also invalidates every OIDC refresh token
|
||||
your apps were holding. They'll bounce the user to a fresh login once, then
|
||||
recover. It's a one-time annoyance, not a bug.
|
||||
|
||||
## Pitfall 4: trusted proxies are now opt-in (2026.8)
|
||||
|
||||
2026.8 tightened the default forwarded-header handling. Previously authentik
|
||||
trusted all the private ranges; now it only trusts what you list explicitly.
|
||||
Behind a Synology reverse proxy forwarding to `localhost`, that means:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: 127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128
|
||||
```
|
||||
|
||||
Skip this and the proxy headers get rejected, which surfaces as auth failures
|
||||
that look like almost anything except what they actually are.
|
||||
|
||||
## Pitfall 5: the one that broke SSO — authorization_flow vs authentication_flow
|
||||
|
||||
This was the expensive mistake, and it's the kind of thing that's easy to get
|
||||
wrong if you're doing per-application branding.
|
||||
|
||||
A provider in authentik has **two** flow fields, and they mean different things:
|
||||
|
||||
```python
|
||||
# "Flow used for authentication when the associated application is
|
||||
# accessed by an un-authenticated user." ← the LOGIN page
|
||||
authentication_flow = models.ForeignKey(...)
|
||||
|
||||
# "Flow used when authorizing this provider." ← the OAuth consent page
|
||||
authorization_flow = models.ForeignKey(...)
|
||||
```
|
||||
|
||||
- `authentication_flow` is the **login page**. This is where you put a
|
||||
per-application flow to customise the title and background.
|
||||
- `authorization_flow` is the **consent/authorize step** for a user who is
|
||||
*already* logged in.
|
||||
|
||||
I wanted different background images per app, so I created one flow per
|
||||
application and pointed the provider's `authorization_flow` at it. Instant
|
||||
breakage: a user who was already authenticated still got walked through the
|
||||
full identification → password → MFA flow every time they opened another app.
|
||||
Single Sign-On was, effectively, gone.
|
||||
|
||||
The fix was a single UPDATE to put the two fields back where they belong:
|
||||
|
||||
```sql
|
||||
UPDATE authentik_core_provider
|
||||
SET authorization_flow_id = '1d85b1b1-...', -- explicit-consent flow
|
||||
authentication_flow_id = 'aa##-per-app-flow'
|
||||
WHERE ...;
|
||||
```
|
||||
|
||||
And the branding itself — the per-app background and title — goes on the
|
||||
**flow's own** `background` and `title` fields (writable since 2026.8), not on
|
||||
the brand's domain match.
|
||||
|
||||
There was a second, related gotcha hiding behind this one. When I created the
|
||||
18 per-app flows, they all landed with `designation=authentication`, and my
|
||||
`auth.hoelee.com` brand had `flow_authentication` set to `NULL`. authentik's
|
||||
fallback when a brand has no explicit auth flow is to pick the first
|
||||
authentication flow **by slug, alphabetically** — which happened to be
|
||||
`auth-agent`, not `default-authentication-flow`. So the root login page started
|
||||
showing my agent's background image. Setting the brand's `flow_authentication`
|
||||
to the real default flow fixed it.
|
||||
|
||||
And one more that compounds with the RBAC pitfall: creating a flow sets its
|
||||
`background` but **not its stages** — `stages` is read-only on the flow object.
|
||||
An empty flow with no stage bindings is exactly what produced an infinite
|
||||
redirect loop on the login page earlier in the migration. Stage bindings are
|
||||
created separately:
|
||||
|
||||
```
|
||||
POST /api/v3/flows/bindings/ # { target: "<flow pk>", stage: "<stage pk>", order: N }
|
||||
```
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
The whole ordeal came down to three preventable patterns:
|
||||
|
||||
1. **Never guess at a field's semantics** — I treated `authorization_flow` as
|
||||
"the login flow" when the source-of-truth is the model definition, which
|
||||
spells out the difference in the field's own docstring.
|
||||
2. **Keep the API surface at arm's length** — API tokens kept expiring mid-run
|
||||
with every server restart, so I ended up doing the critical fixes directly
|
||||
against the database with `psql`. Reliable, but worth scripting *before*
|
||||
the cluster is on fire, not during.
|
||||
3. **One breaking change per restart** — I tried to reason about storage,
|
||||
RBAC, and proxy changes all in one go. Each would have been trivial if
|
||||
isolated and verified independently.
|
||||
|
||||
## The result
|
||||
|
||||
authentik now runs **2026.8.1** — current, patched, all containers healthy —
|
||||
with 18 applications each showing their own background and title on the login
|
||||
page, and SSO working across every subdomain. Seventeen of the applications I
|
||||
use daily went from "asks me to log in again every time I switch apps" back to
|
||||
"log in once, move freely."
|
||||
|
||||
The lesson worth carrying: a major-version upgrade on auth infrastructure is
|
||||
roughly 10% "change the image tag" and 90% "the data model, storage layout,
|
||||
and proxy rules all shifted underneath you." Back up, go one version at a time,
|
||||
and when something behaves in a way that makes no sense, read the field name
|
||||
again before you reach for another config.
|
||||
|
||||
---
|
||||
|
||||
## Want single sign-on for your business?
|
||||
|
||||
If you're running several internal apps — a dashboard, an email server, a help
|
||||
desk, a file server — and your team is still logging into each one separately
|
||||
(or reusing the same password everywhere), I set up and maintain exactly this
|
||||
kind of infrastructure. I'll deploy authentik, wire it to your existing apps,
|
||||
add MFA, and make sure "log in once" actually works — then leave you with a
|
||||
handover so you're never locked in.
|
||||
|
||||
Reach me at [[email protected]](mailto:[email protected]) or WhatsApp
|
||||
[+60 12-797 2969](https://wa.me/60127972969), or see what I do at
|
||||
[hoelee.com](https://hoelee.com).
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
title: "Automating CyberPanel Without the UI: Reverse-Engineering an Undocumented v2 API"
|
||||
description: "CyberPanel v2 killed the documented JSON API and left only the Angular UI's own session+CSRF endpoints. Here's the exact auth flow and how I found the real functions behind the pages."
|
||||
pubDate: 2026-09-09
|
||||
category: devops
|
||||
tags: [cyberpanel, api, reverse-engineering, automation, django, self-hosting, curl]
|
||||
ogImage: /og/automating-cyberpanel-without-the-ui.png
|
||||
banner: /banners/automating-cyberpanel-without-the-ui.png
|
||||
---
|
||||
|
||||
I run CyberPanel 2.4.4.1 inside an Ubuntu VM on my home lab to host a handful
|
||||
of small sites. It sits at `https://panel.hoelee.com`, reverse-proxied to an
|
||||
internal box. Recently I wanted to script two things against it — list my
|
||||
sites and delete one — without opening the browser. This is the story of
|
||||
finding that the "official" API doesn't exist anymore, and reverse-engineering
|
||||
the real one from what the Angular UI quietly calls under the hood.
|
||||
|
||||
## The problem: the documented API is gone
|
||||
|
||||
Every search result, the Apiary docs, and the Knowledge Base point you at the
|
||||
same thing — a JSON API where you `POST` your `adminUser` and `adminPass` to
|
||||
`/api/verifyConnection`:
|
||||
|
||||
```bash
|
||||
# What the old docs tell you to do. This does NOT work on CyberPanel v2.
|
||||
curl -k -X POST https://panel.hoelee.com:8090/api/verifyConnection \
|
||||
-d '{"adminUser":"admin","adminPass":"...","serverUserName":"..."}'
|
||||
```
|
||||
|
||||
On CyberPanel v2 (2.4.x), this returns nothing useful. The `/api/` prefix is
|
||||
the legacy surface — it was dropped. There's no `adminUser`/`adminPass`
|
||||
exchange anymore. The official documentation is simply out of date, which is
|
||||
the first trap: you can spend a long time trusting docs that describe a
|
||||
version you're not running.
|
||||
|
||||
## What I tried, and why it failed
|
||||
|
||||
**Attempt 1 — trust the docs.** `POST /api/verifyConnection` with credentials,
|
||||
exactly as Apiary says. Result: 404. The route doesn't exist.
|
||||
|
||||
**Attempt 2 — hammer the obvious paths.** I tried `/api/`, `/verifyLogin` with
|
||||
an `email` field, a bare `GET` on a few guesses. I got back a response I kept
|
||||
misreading:
|
||||
|
||||
```
|
||||
"This request need session."
|
||||
```
|
||||
|
||||
That message is actually *good news* — it means the API **is** mounted and
|
||||
reachable, it just refuses to talk without a valid session. The endpoint isn't
|
||||
missing; the auth is different.
|
||||
|
||||
**Attempt 3 — the CSRF wall.** I finally sent the right fields to
|
||||
`/verifyLogin` and hit a hard **403 Forbidden**. That's the part that stalls
|
||||
most people: POSTs to a Django-backed panel are protected by a CSRF token, and
|
||||
a bare JSON POST with no token gets dropped before your credentials are even
|
||||
looked at.
|
||||
|
||||
The breakthrough was treating the panel not as "a thing with an API" but as
|
||||
**a Django app with an Angular front-end** — and then just watching what the
|
||||
front-end sends.
|
||||
|
||||
## The fix: the real auth flow
|
||||
|
||||
The whole thing is three steps, all with plain `curl` (and `-k` since the
|
||||
panel uses a self-signed cert):
|
||||
|
||||
### 1. Grab the CSRF token from the login page
|
||||
|
||||
Django sets a `csrftoken` cookie on the very first GET. That cookie's value is
|
||||
the token you echo back in a header on every write:
|
||||
|
||||
```bash
|
||||
curl -sk -c /tmp/cp_cookies.txt "https://192.168.1.124:8090/" -o /dev/null
|
||||
CSRF=$(grep csrftoken /tmp/cp_cookies.txt | awk '{print $7}')
|
||||
```
|
||||
|
||||
### 2. Log in to get a session cookie
|
||||
|
||||
`POST /verifyLogin` with a JSON body and the CSRF token in an `X-CSRFToken`
|
||||
header:
|
||||
|
||||
```bash
|
||||
curl -sk -b /tmp/cp_cookies.txt -c /tmp/cp_cookies.txt \
|
||||
-X POST "https://192.168.1.124:8090/verifyLogin" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRFToken: $CSRF" \
|
||||
-H "Referer: https://192.168.1.124:8090/" \
|
||||
-d '{"username":"admin","password":"...","languageSelection":"EN","twofa":""}'
|
||||
# => {"userID": 1, "loginStatus": 1, "error_message": "None"}
|
||||
```
|
||||
|
||||
`loginStatus: 1` means the session cookie is now valid. The `Referer` header
|
||||
matters more than you'd expect — some of these views check it.
|
||||
|
||||
### 3. Call the data endpoints
|
||||
|
||||
The "API" is just the same POST endpoints the Angular UI calls.
|
||||
`/<module>/<function>`, same cookie jar, same `X-CSRFToken` + `Referer`:
|
||||
|
||||
```bash
|
||||
curl -sk -b /tmp/cp_cookies.txt \
|
||||
-X POST "https://192.168.1.124:8090/websites/fetchWebsitesList" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRFToken: $CSRF" \
|
||||
-H "Referer: https://192.168.1.124:8090/" \
|
||||
-d '{"page":1,"recordsToShow":50}'
|
||||
```
|
||||
|
||||
That returned every one of my sites, with SSL status, disk usage, PHP version,
|
||||
and per-site days-until-cert-expiry. The same pattern deletes a site:
|
||||
|
||||
```bash
|
||||
curl -sk -b /tmp/cp_cookies.txt \
|
||||
-X POST "https://192.168.1.124:8090/websites/submitWebsiteDeletion" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-CSRFToken: $CSRF" \
|
||||
-H "Referer: https://192.168.1.124:8090/" \
|
||||
-d '{"websiteName":"blog.hoelee.com"}'
|
||||
```
|
||||
|
||||
## The two gotchas that ate most of the time
|
||||
|
||||
**1. The 404 on `/api/...` was never a network problem — it was the wrong era.**
|
||||
The `/api/` prefix belongs to the old API. Modern CyberPanel uses
|
||||
root-relative `/<module>/<function>` with no `/api/` segment. Once I stopped
|
||||
looking for an "API" and started looking for the UI's own routes, everything
|
||||
snapped into place.
|
||||
|
||||
**2. List responses are double-encoded JSON.** `fetchWebsitesList` returns
|
||||
something shaped like:
|
||||
|
||||
```json
|
||||
{ "data": "[{\"domain\":\"...\",\"ssl\":{\"days\":17}, ...}]" }
|
||||
```
|
||||
|
||||
The `data` key is a **JSON-encoded string**, not an array. If you pipe it
|
||||
straight into `jq` and try `.[]`, you get nothing sensible. You have to unwrap
|
||||
one layer first:
|
||||
|
||||
```bash
|
||||
curl ... | jq -r .data | jq # unwrap the string, then parse again
|
||||
```
|
||||
|
||||
## How to find the other endpoints
|
||||
|
||||
The function names aren't random — they map directly to Django view functions
|
||||
in the CyberPanel source (`usmannasir/cyberpanel` on GitHub, `stable` branch).
|
||||
The relevant files are `websiteFunctions/views.py`, `mailFunctions/views.py`,
|
||||
`manageSSL/views.py`, and so on. So the workflow is:
|
||||
|
||||
1. Find the page in the UI (say, "Create Website").
|
||||
2. Open the browser's network tab and watch what URL it POSTs to — or grep the
|
||||
matching `views.py` for the view name.
|
||||
3. Call the same `/<module>/<function>` path with your session + CSRF headers.
|
||||
|
||||
A useful distinction: a `GET /<module>/<page>` usually returns the **HTML of
|
||||
the UI page**, while the **data** comes from a POST to a sibling function. If
|
||||
you GET a page and get markup back, you haven't found the real endpoint yet —
|
||||
keep looking for the AJAX call.
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
1. **Start from the front-end, not the docs.** Watching the Angular app's
|
||||
network requests would have saved me the whole "trust the obsolete Apiary
|
||||
docs" detour. The UI is always the ground truth for its own API.
|
||||
2. **Treat "This request need session." as a beacon, not an error.** The first
|
||||
few times I read it as "wrong endpoint" when it was really "right endpoint,
|
||||
wrong session state."
|
||||
3. **Script the login once into a reusable helper.** The session cookie dies
|
||||
on VM reboot and on expiry, so every one-off `curl` started from scratch.
|
||||
A tiny wrapper that logs in, captures the cookie, and re-logins on
|
||||
`"This request need session."` would have made the whole session repeatable
|
||||
instead of exploratory.
|
||||
|
||||
## The result
|
||||
|
||||
I can now list, create, and delete sites on my CyberPanel panel entirely from
|
||||
the shell — no browser, no GUI — and the whole authenticated API surface is
|
||||
open for scripting (SSL, mail, DNS, cron). I found this by scrapping the docs,
|
||||
watching the real requests, and mapping them back to their Django views, and
|
||||
the `cyberpanel` module + session flow now lives in my own automation toolkit.
|
||||
|
||||
The broader lesson: "it's not documented" rarely means "it's not possible."
|
||||
When a tool exposes a web UI, that UI is a living, exact reference for the API
|
||||
— you just have to watch what it actually sends.
|
||||
|
||||
---
|
||||
|
||||
## Want your server administration scripted?
|
||||
|
||||
If you're clicking through a hosting panel — or worse, doing the same manual
|
||||
steps across several servers — I automate exactly this kind of thing: turn
|
||||
repetitive admin into a tested script or a small internal tool, wired to your
|
||||
existing stack, with a handover so you're never locked in. Whether it's
|
||||
CyberPanel, cPanel, Docker, or a bespoke dashboard, if it has a web UI, it can
|
||||
almost certainly be driven without one.
|
||||
|
||||
Reach me at [[email protected]](mailto:[email protected]?subject=Scripting%20my%20server%20admin) or WhatsApp
|
||||
[+60 12-797 2969](https://wa.me/60127972969), or see what I do at
|
||||
[hoelee.com](https://hoelee.com).
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
title: "Best AI Video Generators in 2026: Free vs Paid, and My LTX-2.3 Test"
|
||||
description: "Compare the best AI video generators in 2026 — free vs paid plans, real pricing, local open-weight models, and my hands-on LTX-2.3 test."
|
||||
pubDate: 2026-09-11
|
||||
category: ai
|
||||
tags: [ai-video, video-generation, ltx, open-weights, pricing, veo]
|
||||
ogImage: /og/best-ai-video-generators-2026.png
|
||||
banner: /banners/best-ai-video-generators-2026.png
|
||||
---
|
||||
|
||||
Which AI video generator should you actually use in 2026 — and how much of it can you really get for free?
|
||||
|
||||
AI video has changed faster than almost any other creative tool I've used. A year ago, decent video generation meant a paid subscription and hoping for the best. Today you can generate synchronized video *and* audio with an open-weight model on your own GPU, or get genuinely useful free credits from half a dozen platforms.
|
||||
|
||||
But there's a catch hiding in the word "free". It means at least four different things in this market, and most articles don't tell you which one they're talking about. This guide compares the options that are actually usable in 2026, separates marketing from reality, and ends with a hands-on test: I generated a short video with the open-weight model **LTX-2.3** through PinkCherry, and I'll show you the result.
|
||||
|
||||
*Pricing and free quotas below were checked on September 11, 2026. This market moves monthly — always verify against the provider's current pricing page before you commit.*
|
||||
|
||||
## What "free" actually means in 2026
|
||||
|
||||
Before comparing tools, you need to decode the word. There are four different deals hiding behind "free AI video generator":
|
||||
|
||||
1. **Free daily or monthly credits** — you get a set number of credits that refresh. Pika (80/month), PixVerse (daily), and Kling (66/day) work this way.
|
||||
2. **A one-time free trial** — you get an allocation once, and it never comes back. Runway's 125 credits are the classic example.
|
||||
3. **Open weights** — the model itself is free to download and run. But "free model" doesn't mean free *compute*: you still need a GPU, electricity, and setup time.
|
||||
4. **A hosted demo or community platform** — someone else runs the model for you (like PinkCherry, where I tested LTX-2.3). Quotas, queues, and availability can change without notice.
|
||||
|
||||
Same word, wildly different economics. A platform advertising "free AI video" is usually meaning #1 or #2. A model card advertising "free" is meaning #3 — and it quietly shifts the cost to your hardware.
|
||||
|
||||
## Why "model" and "platform" aren't the same thing
|
||||
|
||||
Another distinction that trips people up: **Seedance 2.5, Kling, and Veo are models. PixVerse, fal.ai, and Runway are platforms** — and platforms often resell each other's models.
|
||||
|
||||
Concrete example: Seedance 2.5 is ByteDance's model. You can use it through ByteDance's own products, through PixVerse (where it generates up to 30-second clips), or through fal.ai's API. Each route has different pricing, and none of those prices is "the" Seedance price. Kling is even messier — its own subscription copy references "Video 2.6" while third parties resell a "Kling 3.0" endpoint, so trust the model picker, not the blog posts.
|
||||
|
||||
This matters because comparing "Seedance vs PixVerse" is comparing a model against a platform. Compare like with like: model-to-model, platform-to-platform, and API-to-API.
|
||||
|
||||
## Quick comparison
|
||||
|
||||
| Tool | Free option | Paid option | Max clip | Native audio | Open/local | Best for |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Pika | 80 credits/mo, 480p, watermarked | from ~$8/mo | ~5–10s | SFX | No | fast social clips |
|
||||
| PixVerse | 90 signup + 60 daily credits | from ~$10/mo | up to 30s (Seedance 2.5) | model-dependent | No | many models in one place |
|
||||
| Kling | 66 credits/day, 5s, 720p, non-commercial | from ~$7–10/mo | 10s | yes (paid) | No | value + motion physics |
|
||||
| Seedance 2.5 (via fal.ai API) | none | ~$0.22–1.16/s | up to 30s | yes | No | cinematic short clips |
|
||||
| Veo 3.1 (Gemini API) | none on API (limited free in Gemini app) | $0.05–0.40/s | 8s (+ extend) | yes | No | top quality |
|
||||
| Runway | 125 one-time credits | from ~$12–15/mo | 10s + extend | via bundled models | No | creative editing workflow |
|
||||
| Hailuo (MiniMax) | generous daily credits, watermarked | from ~$10–15/mo | 6s | no | No | free previz |
|
||||
| Luma (Ray3) | ~30 gen/mo, watermarked | from ~$25–30/mo | ~10s | no | No | cinematic look |
|
||||
| LTX-2.3 / LTX-2.5 | open weights, free under $10M ARR | LTX API from ~$0.09/s | ~10s | yes | Yes | local / self-hosted |
|
||||
| Wan 2.2 | open weights (Apache 2.0) | — | ~5–10s | no | Yes | local, no audio needed |
|
||||
| Sora 2 | — shutting down Sept 24, 2026 | $0.10–0.70/s until sunset | up to 20s | yes | No | avoid — being retired |
|
||||
|
||||
More detail on each below. Remember: **credits are not seconds**. "2,000 credits" sounds huge until you learn one 10-second 1080p clip costs 70–100 of them.
|
||||
|
||||
## 1. Pika — the cheapest real starting point
|
||||
|
||||
Pika's free Basic plan gives you **80 monthly credits** — roughly five or six 5-second 480p clips a month. It's watermarked, capped at 480p, and has no commercial use and no watermark-free download (those start on paid plans). Several older articles claim Pika's free tier is watermark-free; as of September 2026 that is *not* true.
|
||||
|
||||
Paid plans start around **$8/month**. For the money you get faster generation, higher resolution, Pikaffects (explosion, melt, morph effects built for virality), and keyframe control.
|
||||
|
||||
**Best for:** people who want the lowest-friction way to make fun short-form clips for social media. Not for serious cinematic work — raw quality trails Veo, Kling, and Seedance.
|
||||
|
||||
## 2. PixVerse — many models, one credit balance
|
||||
|
||||
PixVerse is the clearest example of the model-vs-platform distinction. One account, one credit pool, and a model selector that includes **Seedance 2.5, Seedance 2.0, Kling, Veo 3.1, and its own V6** — which makes it the easiest place to compare models side-by-side without five subscriptions.
|
||||
|
||||
Free tier: **90 signup credits plus 60 daily credits** — roughly a video or two a day depending on model and resolution. Paid plans start around $10/month and unlock higher resolutions and the bigger models (Seedance 2.5 itself is currently gated to paid members).
|
||||
|
||||
Pricing is per-second and varies wildly by model and resolution — Seedance 2.5 costs far more per second than the in-house V6. That's why comparing tools by "price per video" without naming the model and resolution is meaningless.
|
||||
|
||||
**Best for:** people who want to try many models in one place and compare output quality directly.
|
||||
|
||||
## 3. Kling — best value for motion, with a confusing tier ladder
|
||||
|
||||
Kling (Kuaishou) is the value leader in 2026. Its **Kling 3.0** model (released February 2026) tops the Artificial Analysis image-to-video leaderboard, with notably believable physics — cloth, water, hair.
|
||||
|
||||
The free tier gives **66 credits per day** (they reset every 24 hours, they don't accumulate, and the exact amount can vary by region and account age). The catch is on three levels: output is **watermarked**, **non-commercial**, and — the part most reviews bury — free users get the *older* Kling 2.1 at 5 seconds and 720p. Kling 3.0 starts on paid plans.
|
||||
|
||||
Paid plans list from about **$10/month** (intro promos around $7, renewal higher) for 660 credits. Watch two gotchas: subscription credits don't roll over, and failed generations still deduct credits.
|
||||
|
||||
**Best for:** creators who want the strongest motion quality per dollar and don't need the free tier for anything commercial.
|
||||
|
||||
## 4. Seedance 2.5 — powerful, but read the pricing carefully
|
||||
|
||||
Seedance 2.5 is ByteDance's current flagship. It does text-to-video, image-to-video, and **reference-to-video** (multiple reference images for visual consistency), with native audio and generation up to **30 seconds** — unusually long for a single clip.
|
||||
|
||||
There is no universal Seedance price, because the model isn't sold directly at one price. On **fal.ai**, an independent API provider, approximate standard pricing is:
|
||||
|
||||
- **480p: ~$0.22/second**
|
||||
- **720p: ~$0.47/second**
|
||||
- **1080p: ~$1.16/second** (rolled out August 2026)
|
||||
|
||||
A 10-second 720p clip on fal runs about **$4.70**. WaveSpeed lists 1080p around $0.90/second. On PixVerse, Seedance 2.5 is available to paid members at per-second credit rates. These are *third-party* prices — ByteDance's own consumer products price differently — but they're the realistic numbers if you're building on an API.
|
||||
|
||||
**Best for:** filmmakers and developers who want cinematic, consistent short clips and are comparing API economics, not consumer subscriptions.
|
||||
|
||||
## 5. Google Veo 3.1 — the quality crown, with three speed tiers
|
||||
|
||||
Most independent testing in 2026 puts **Veo 3.1** at the top for realism and prompt accuracy, and it generates synchronized audio in the same pass. Google's official API documentation lists 8-second clips at 720p, 1080p, or 4K, with extension, first/last-frame control, and up to three reference images.
|
||||
|
||||
The API has **no free tier** — everything is pay-per-second:
|
||||
|
||||
- **Veo 3.1 Lite: $0.05/s** (720p) — cheap iteration
|
||||
- **Veo 3.1 Fast: $0.10/s** (720p), $0.12/s (1080p), $0.30/s (4K)
|
||||
- **Veo 3.1 Standard: $0.40/s** (720p and 1080p), $0.60/s (4K)
|
||||
|
||||
Consumers can try Veo free inside the Gemini app on limited daily usage — the API itself is paid-only. Note that Veo 3 was deprecated on June 30, 2026; 3.1 is the current line, and Google has also previewed **Gemini Omni Flash**, a video generation *and editing* model around $0.10/s.
|
||||
|
||||
**Best for:** anyone who wants the best output and is fine paying per clip — and developers who want predictable per-second API costs.
|
||||
|
||||
## 6. Runway — a creative workflow, not just a generator
|
||||
|
||||
Runway has become a video *workspace*: its Gen-4.5 model plus a real editing timeline, motion brushes, inpainting, and character consistency — and it now **resells Veo 3.1, Kling, and Seedance inside the same editor**. One subscription, many models. That aggregation is the real reason to pay.
|
||||
|
||||
The free tier is the clearest example of "one-time trial": **125 credits, once, ever**. They don't renew, the free tier is image-to-video only with a watermark, and it can't touch Gen-4.5 or Veo.
|
||||
|
||||
Paid: Standard ~$12–15/month for 625 credits, Pro ~$28–35/month for 2,250, Max ~$76–95/month. Do the credit math before buying: Gen-4.5 burns roughly 12–25 credits per second, so the Standard plan's 625 credits is only about **25–50 seconds** of flagship video a month.
|
||||
|
||||
**Best for:** creators who need to stitch clips into longer cuts, hold a consistent character, and edit — not just prompt.
|
||||
|
||||
## 7. Hailuo and Luma — the "worth knowing" pair
|
||||
|
||||
**Hailuo (MiniMax)** has the most genuinely usable free tier for evaluation: generous daily credits, watermarked, 6-second clips. Quality for realistic human motion punches well above its price. Paid from ~$10–15/month; API around $0.07–0.08/second makes it one of the cheapest serious options for developers.
|
||||
|
||||
**Luma (Ray3)** is the rare tool with a real HDR and color pipeline (16-bit HDR, EXR export) aimed at actual film workflows. Free tier gives roughly 30 watermarked generations a month; paid plans sit around $25–30/month — pricier than Pika or Kling, and the free allowance is small. It's the pick if you want a cinematic look and work in film-oriented formats.
|
||||
|
||||
## What happened to Sora 2 — and why you should care
|
||||
|
||||
If you've read older guides, you'll notice Sora 2 is missing from the recommendations. OpenAI **discontinued the Sora app on April 26, 2026**, and the Sora API is scheduled to **shut down on September 24, 2026**. As of this writing, you cannot buy a Sora subscription, and building on the API means migrating in days.
|
||||
|
||||
It's a useful lesson in how fast this market turns: a model that was "best overall" in early 2026 is gone before the year ends. When you choose a platform or model for real work, check *when* it was last updated and whether the company is still shipping — the best model in the world is useless if the API sunset is next month.
|
||||
|
||||
## LTX-2.3 and LTX-2.5 — the interesting open/local route
|
||||
|
||||
This is the part that changes the economics. **LTX** is the open-weights video line from Lightricks (now spun out as LTX). It's an audio-video foundation model: video and synchronized audio generated in a single pass — dialogue, lip-sync, ambience — which was a hosted-only capability until recently.
|
||||
|
||||
- **LTX-2.3** (March 2026) — the version I tested. Open weights, synchronized audio, portrait and landscape.
|
||||
- **LTX-2.5** (August 11, 2026) — the current release. 22B parameters, native **multishot** generation (several connected scenes in one output, holding character, lighting, and voice across cuts), 4K HDR, a new diffusion video decoder with fewer artifacts, and a **Gemma 4** text encoder. Day-one ComfyUI support.
|
||||
|
||||
Licensing matters here: it's the LTX Community License, **free for commercial use if your company earns under $10M ARR** — no mandatory branding, no per-seat fees. Above that, you negotiate a paid license.
|
||||
|
||||
You can run it three ways: self-hosted on your own GPU (roughly 16–24GB VRAM reported for reasonable speeds — check the current requirements), through ComfyUI workflows, or via the LTX API (from about **$0.09/second at 720p**).
|
||||
|
||||
Why this is a big deal: an open-weights model with synchronized audio changes the cost question from "what's the subscription?" to "what's my GPU budget?". The trade-off is real — setup time, hardware cost, and results that depend heavily on your workflow, quantization, and settings. But for experimentation, learning, and full control, nothing else in this list compares.
|
||||
|
||||
## My LTX-2.3 test
|
||||
|
||||
I generated this short clip with **LTX-2.3** in my own ComfyUI setup — a local install I've nicknamed **PinkCherry**. It's a real-world test, not a benchmark — no claims about speed or quality leadership here, just what the model did when I pointed it at a prompt and let it work.
|
||||
|
||||
<video controls playsinline preload="metadata" poster="https://content.hoelee.com/file/hoelee/video/LTX23-Demo.jpg">
|
||||
<source src="https://content.hoelee.com/file/h_480/hoelee/video/LTX23-Demo.mp4" type="video/mp4" />
|
||||
Your browser does not support HTML5 video.
|
||||
</video>
|
||||
|
||||
*My LTX-2.3 demo clip, generated locally in ComfyUI with the PinkCherry workflow (hosted on content.hoelee.com).*
|
||||
|
||||
What made this experiment interesting wasn't that LTX beats the commercial models — on raw polish it doesn't need to, and I'm not going to claim otherwise. It's that this is an **open-weight, local-oriented model**:
|
||||
|
||||
- **Different cost structure.** No subscription, no per-clip API bill. The cost moves to GPU hardware, electricity, and setup time — which is a *capital* cost you can amortize, not a recurring one.
|
||||
- **More control.** You can run it in ComfyUI, swap checkpoints, quantize to fit your VRAM, and fine-tune under the community license.
|
||||
- **It's the model's job to evolve fast.** I tested 2.3; 2.5 shipped a month later with multishot and 4K HDR. Open weights mean you don't wait for a company to upgrade you.
|
||||
|
||||
The honest caveats: your results depend heavily on hardware, workflow, model version, and quantization. A hosted playground (like a Hugging Face Space or ComfyUI.cloud) is meaning #4 of "free" — great for a first taste, but queues and quotas can change, and serious local use means learning ComfyUI.
|
||||
|
||||
If you want to try it yourself: grab the weights from Hugging Face and a community ComfyUI pack, or use a hosted playground for a first test, then decide if local setup is worth it. For me, it was — which is exactly why this article exists.
|
||||
|
||||
### How I made it: the LTX-2.3 ComfyUI workflow
|
||||
|
||||
Here's how the test above was actually made — the workflow behind the clip. Nothing exotic: just the standard LTX-2.3 parts, all free nodes:
|
||||
|
||||
- **Image-to-video pipeline with a second pass.** A reference image becomes a video latent, gets sampled, then runs through a **2× spatial latent upscaler** for the final pass.
|
||||
- **Separate video and audio VAEs.** LTX-2.3 generates synchronized audio in the same pass, and it needs its own audio VAE alongside the video VAE — that's the piece that makes the sound *part of* the generation instead of an afterthought.
|
||||
- **Gemma 3 12B text encoder.** The LTX line uses Gemma as its language backbone, loaded with the LTX text projection.
|
||||
- **Distilled checkpoint + LoRA.** The fast distilled variant with the distilled LoRA at 0.6 strength, at **24 fps and 5–7 second clips** — the practical sweet spot for LTX.
|
||||
- **GGUF quantization + Chunk FeedForward.** The model loaded GGUF-quantized, with chunked feed-forward layers — that's how a 22B model fits on consumer VRAM.
|
||||
- **Negative audio guidance (NAG).** Separate negative prompts for the video track and the audio track (e.g. "voice over, narration, off-camera speech" for audio), which keeps the generated sound clean.
|
||||
- **A tiny preview VAE** for fast in-sampler previews instead of full decodes.
|
||||
|
||||
The prompt matters more than any single node. LTX rewards prompts that describe action over time and weave the audio layer in from the start — its own guidance (reproduced from the workflow's notes):
|
||||
|
||||
1. **Core actions:** describe events and actions as they occur over time.
|
||||
2. **Audio:** describe sounds and dialogue needed for the scene.
|
||||
3. **Reference image:** don't repeat details already present.
|
||||
4. **Consistency:** avoid instructions that don't match the reference image — they degrade results.
|
||||
|
||||
<details>
|
||||
<summary>The exact prompt from my workflow (tap to expand)</summary>
|
||||
|
||||
> A cinematic futuristic night scene in a dense neon-lit city alley during a heavy rainstorm. A young Asian female courier in a dark waterproof jacket, black cargo pants and a compact glowing backpack runs quickly toward the camera, water splashing from her boots with every step, loose strands of wet hair moving naturally in the wind. The shot begins behind and slightly above her as she runs through the narrow alley, then the camera smoothly tracks alongside her and arcs around to the front, revealing her focused face as she looks briefly toward the camera while continuing to run. A small sleek hovering surveillance drone follows several meters behind her, its white searchlight sweeping through the rain. Neon signs reflect vividly across the wet pavement, puddles ripple from raindrops, mist drifts through the alley, and colored light flickers across her face and clothing. The camera movement remains smooth and cinematic with realistic handheld micro-motion, shallow depth of field, natural motion blur and strong foreground-to-background parallax. The final moment shows her rushing past the camera while the drone flies overhead, leaving the camera facing the glowing rainy alley. Realistic cinematic lighting, physically believable rain and water interaction, detailed skin, fabric and wet surfaces, high environmental detail, dramatic science-fiction atmosphere. Audio: heavy rainfall, footsteps splashing through puddles, distant city traffic, subtle electrical ambience and the quiet mechanical hum of the hovering drone.
|
||||
|
||||
</details>
|
||||
|
||||
Notice the shape: the action runs top to bottom, camera moves are specified but not over-specified, and the **audio layer is spelled out inline** ("heavy rainfall, footsteps splashing through puddles...") rather than tacked on at the end. That's the LTX prompting style in a nutshell.
|
||||
|
||||
If you want to reproduce this setup: the community-ready LTX-2.3 ComfyUI model pack is at [huggingface.co/Kijai/LTX2.3_comfy](https://huggingface.co/Kijai/LTX2.3_comfy), with the text encoder at [huggingface.co/Comfy-Org/ltx-2](https://huggingface.co/Comfy-Org/ltx-2). The nodes I used ship with ComfyUI core and KJNodes.
|
||||
|
||||
## Can you make a 3-minute AI video?
|
||||
|
||||
The honest answer: not in one shot. Even models with 30-second generation (Seedance 2.5) or extension (Veo) are **short-clip generators**, not feature-film machines. Maximum duration is not the same as the ability to produce a polished long-form video — coherence, consistency, and pacing all degrade the longer a single generation runs.
|
||||
|
||||
The practical workflow everyone actually uses:
|
||||
|
||||
```
|
||||
Script → scenes → short AI clips (10–15s each) → edit → voice/music/sound → final video
|
||||
```
|
||||
|
||||
The math is simple: **15 × 12-second clips = 180 seconds = 3 minutes.** A 3-minute YouTube video is fifteen generated clips, cut together, with audio layered on top. That's why the "max clip length" column in my table matters less than you'd think — you're going to edit regardless.
|
||||
|
||||
## Free vs paid: what should you choose?
|
||||
|
||||
There is no single winner — the right tool depends on who you are:
|
||||
|
||||
| You are... | Try this |
|
||||
|---|---|
|
||||
| Complete beginner | Pika or PixVerse free credits — learn the workflow at $0 |
|
||||
| Casual creator, no commercial need | Hailuo's daily credits — best free quality |
|
||||
| Budget social-media creator | Kling Standard (~$7–10/mo) or Pika (~$8/mo) |
|
||||
| YouTube / short-form creator | Kling for clips + Runway for editing, or PixVerse for multi-model comparison |
|
||||
| Business / product video | Paid tier with commercial rights — Veo (via API), Kling, or Runway |
|
||||
| Developer building an app | Compare APIs: Veo Lite ($0.05/s), Seedance via fal, Kling via partners, LTX API (~$0.09/s) |
|
||||
| You own a decent GPU | LTX-2.5, Wan 2.2, or HunyuanVideo 1.5 — free per clip, cost is hardware |
|
||||
|
||||
The four questions that decide it: **how often** do you generate, **do you need commercial use**, **do you need native audio**, and **do you already own a suitable GPU**. Answer those and the table above picks itself.
|
||||
|
||||
## Final thoughts
|
||||
|
||||
2026's AI video landscape rewards people who read the fine print. "Free" is four different deals. Credits are not seconds. Models are not platforms. And the market moves fast enough that a category leader can vanish in a year — Sora is the cautionary tale.
|
||||
|
||||
My practical recommendation: start with **PixVerse** (many models, one credit pool) or **Kling's daily credits** to learn what you actually like, compare against **Veo 3.1** when quality matters, and seriously consider the **LTX open-weight route** if you have any interest in local generation — my own test convinced me it's a genuinely different (and cheaper) path.
|
||||
|
||||
This isn't my first ride on the open-weights train — I wrote about [generating product images locally with Flux Kontext](https://blog.hoelee.com/posts/ai-furniture-compositing-with-flux-kontext/) a while back, and the same lesson applies here: open weights trade setup effort for control and long-term cost.
|
||||
|
||||
And when an article quotes a price — including this one — check the provider's pricing page before you rely on it. **Pricing checked September 11, 2026.**
|
||||
|
||||
### Sources
|
||||
|
||||
- [Gemini API pricing — official Veo 3.1 rates](https://ai.google.dev/gemini-api/docs/pricing)
|
||||
- [Veo 3.1 docs — capabilities and lengths](https://ai.google.dev/gemini-api/docs/veo)
|
||||
- [fal.ai — Seedance 2.5 endpoints and pricing](https://fal.ai/models/bytedance/seedance-2.5/text-to-video)
|
||||
- [OpenAI video generation API pricing](https://developers.openai.com/)
|
||||
- [Pika pricing page](https://pika.art/pricing)
|
||||
- [Kling membership plans](https://kling.ai/app/membership/membership-plan)
|
||||
- [PixVerse — Seedance 2.5 announcement](https://pixverse.ai/en/blog/seedance-2-5-now-available-on-pixverse)
|
||||
- [Runway pricing](https://runwayml.com/pricing)
|
||||
- [LTX-2.5 model card (Hugging Face)](https://huggingface.co/Lightricks/LTX-2.5)
|
||||
- [LTX-2.3 model card (Hugging Face)](https://huggingface.co/Lightricks/LTX-2.3)
|
||||
- [Kijai's LTX-2.3 ComfyUI model pack (Hugging Face)](https://huggingface.co/Kijai/LTX2.3_comfy)
|
||||
- [LTX-2 text encoder (Hugging Face)](https://huggingface.co/Comfy-Org/ltx-2)
|
||||
|
||||
---
|
||||
|
||||
*Interested in AI video, local models, or AI-powered content automation? I build AI pipelines, bots, and self-hosted infrastructure for businesses — if you'd like to generate video at scale, self-host an open-weight model, or automate your content workflow, I'd love to talk:*
|
||||
|
||||
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
|
||||
- 📧 **Email:** [[email protected]](mailto:[email protected]?subject=AI%20video%20automation)
|
||||
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: "Hardening a Tor Onion Service: What Actually Matters"
|
||||
description: "Out of curiosity I mirrored my portfolio and git server onto the darknet. Here's the hardening that held, the parts that silently broke, and whether it's worth offering to clients."
|
||||
pubDate: 2026-09-09
|
||||
category: devops
|
||||
tags: [tor, docker, security, self-hosting, networking]
|
||||
ogImage: /og/hardening-a-tor-onion-service.png
|
||||
banner: /banners/hardening-a-tor-onion-service.png
|
||||
---
|
||||
|
||||
I got curious about the darknet. Not the marketplaces, the boring half of it: people self-hosting things the way the internet worked before registrars and cloud dashboards, running software they control at an address they own.
|
||||
|
||||
The way I answer a curiosity like that is to build something, so the experiment became: mirror my own sites onto Tor. My interactive portfolio lives at [me.hoelee.com](https://me.hoelee.com), and my code lives on a Gitea instance at [git.hoelee.com](https://git.hoelee.com). Both now have darknet twins:
|
||||
|
||||
- `hoeleegitkcng572znkbpyffppyulsdwv3aurrzlk7y7vlhknogswoqd.onion` — the Gitea mirror
|
||||
- `hoeleeaiwowgndbxswegtdzoeupz7lkkechtqmurbmnpvwa4k3vyuyid.onion` — the portfolio mirror
|
||||
|
||||
### If you've never opened a `.onion` before
|
||||
|
||||
These addresses don't work in a normal browser. You need Tor Browser, which routes your traffic through the Tor network. It's a five-minute setup:
|
||||
|
||||
1. Download it from **[torproject.org/download](https://www.torproject.org/download/)**.
|
||||
2. **Verify the signature.** The download page links the `.asc` files, and this step matters — a tampered Tor Browser is the worst possible way to lose your anonymity. Fetch the Tor Browser signing key (the fingerprint is published on the download page and in the Tor Browser manual), then check the file:
|
||||
```bash
|
||||
# import the signing key — see the .asc link on the download page
|
||||
gpg --verify tor-browser-*.tar.xz.asc tor-browser-*.tar.xz
|
||||
```
|
||||
3. Extract and run it. Windows and macOS get a normal installer; on Linux, extract and run `./start-tor-browser.desktop`.
|
||||
4. **Click Connect.** The default settings are fine. If you're on a censored network, choose "Configure connection" and pick a bridge (Snowflake or obfs4).
|
||||
5. Wait for "Connected" and the circuit display in the top-left corner. You're on Tor.
|
||||
|
||||
Now paste one of the addresses above into the URL bar, exactly as written. Two things to know:
|
||||
|
||||
- A v3 address is **56 characters** of base32 (`a–z`, `2–7`) plus `.onion`. There is no typo correction and no search suggestion — one wrong character gives you a dead address with no explanation.
|
||||
- A 16-character address (v2) won't load at all. The v2 protocol was retired in 2021.
|
||||
|
||||
One thing that surprises first-timers: a brand-new onion can take **10–60 minutes** before the network recognises it. Tor has to publish a descriptor and get it accepted into the hash ring. An onion that doesn't load on the first try is usually just young, not broken.
|
||||
|
||||
While I was researching how to do this right, a lot of what I read online talked up the benefits of obfs4. What actually kept my setup safe had nothing to do with that reading.
|
||||
|
||||
So what does actually keep an onion service safe? I went through this properly when I set the mirrors up, and again months later when I went back to check on them. Some of the setup held up. Some of it had quietly broken. And a couple of things I believed about Docker turned out to be wrong in ways I could measure.
|
||||
|
||||
## Tor and onion services, in thirty seconds
|
||||
|
||||
Tor is an anonymity network: your traffic hops through three relays, and no single relay sees both who you are and where you're going. An onion service is the reverse direction, a server that lives *inside* the network. It gets a `.onion` address instead of a domain, visitors reach it only through Tor, and the server's real location never shows up in the connection.
|
||||
|
||||
Four properties come out of that, and a normal website simply can't offer them:
|
||||
|
||||
- **No public IP, no ports, no DNS.** Nothing for scanners to find. You can host it from a home connection behind CGNAT.
|
||||
- **No middlemen.** No CDN, registrar, or platform in the path recording who visits.
|
||||
- **The address itself is the access control.** People who have it can reach the site; everyone else can't even tell it exists. Add client keys on top when that isn't strict enough.
|
||||
- **It keeps working when the clearnet doesn't.** If a domain gets blocked or seized, the onion is untouched, because no registry is involved.
|
||||
|
||||
What onion hosting suits: private file drops, portfolio and git mirrors, lawyer-to-counsel drafts, digital-goods delivery, any archive that should never appear in a search index. The people who want it are the ones for whom "nobody can even see that it exists" is the feature rather than a quirk: lawyers, auditors, freelancers sending deliverables, sources talking to journalists, and shops selling digital products over a channel their competitors can't scan.
|
||||
|
||||
The honest tradeoff is speed. Tor circuits are slower than a CDN, so you wouldn't put a marketing site on one. It's the right tool for the private half of the internet, not the public half.
|
||||
|
||||
## What actually protects the origin
|
||||
|
||||
Three things, and only the last one requires any work:
|
||||
|
||||
1. **The protocol.** Visitors never connect to your server directly. Your tor process dials out, registers the service with introduction points, and rendezvous happens inside the network. Nobody who visits gets your IP from the visit itself.
|
||||
2. **Vanguards-lite.** Built into Tor since 0.4.7, this makes guard-discovery attacks (an attacker forcing circuits until they can observe your guard relay) far less practical. You get it by simply running a current Tor.
|
||||
3. **Not leaking the origin through other channels.** The realistic way an onion host gets exposed is not traffic analysis. It's your own machine leaking: a clearnet service gets compromised, and the attacker just reads your onion keys off the disk. Mirroring a public site to an onion is deliberate, so content correlation doesn't bother me. What matters is that the machine holding the keys isn't also running a pile of exposed services.
|
||||
|
||||
## Things that quietly broke
|
||||
|
||||
These are the parts where re-checking my own server paid for itself.
|
||||
|
||||
**The app container had full internet access.** I ran a one-liner inside the container against a public IP echo service, and my home IP came back. There had been an iptables-based block for this, but the rules were gone. Host firewalls get flushed silently by interface changes, container manager restarts, platform updates. A block that nothing re-applies and nothing alarms on is not a block, it's a superstition. This convinced me to stop filtering the app's egress and remove it entirely instead (below).
|
||||
|
||||
**The host firewall was off.** INPUT and FORWARD policy ACCEPT, and a few dozen ports listening on all interfaces from the other services on the same machine. If any one of those gets owned, the onion keys on that disk belong to the attacker. People spend hours on Tor-specific hardening and skip this.
|
||||
|
||||
**The running tor didn't match its config file.** The torrc on disk said `SocksPort 0`. The process, up for days, was still listening on 127.0.0.1:9050. I had edited the file and never restarted the container. Impact here was small, since the listener was container-local. The lesson generalizes: the config file you wrote is a wish, what the process is actually doing is the truth.
|
||||
|
||||
**Tor was a security release behind.** Older than I'd want on a box holding sensitive keys, and my logs carried the warn-spam signatures from a relay-descriptor parsing bug the newer releases fixed. It stopped after the upgrade.
|
||||
|
||||
## The fixes that stayed fixed
|
||||
|
||||
### Zero egress, by construction
|
||||
|
||||
Both containers go on a Docker network with `internal: true`, and only the tor container gets a second NIC for reaching the Tor network.
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
service_net:
|
||||
driver: bridge
|
||||
internal: true # no gateway, no masquerade, no route out
|
||||
egress:
|
||||
external: true # ordinary bridge with internet
|
||||
```
|
||||
|
||||
The app has no route to anything now. Not the internet, not the LAN, not even the host, because an internal network has no gateway at all. Nothing to flush, no boot task to remember, no way for it to silently decay. If the app gets compromised, the attacker gains a socket pointing at tor and nothing else. This is the one change I'd call non-negotiable for any app I run this way.
|
||||
|
||||
### Non-root, and the capability surprise
|
||||
|
||||
The image runs as non-root by default, and I wanted the app to keep listening on port 80. The standard advice is `cap_add: NET_BIND_SERVICE`. It didn't work. The container crash-looped with:
|
||||
|
||||
```
|
||||
[FATAL] Server error: listen tcp 0.0.0.0:80: bind: permission denied
|
||||
```
|
||||
|
||||
The reason surprised me enough that I measured it with a probe container: `grep Cap /proc/self/status` showed `CapBnd` with bit 10 (NET_BIND_SERVICE) set, and `CapEff` at zero. Docker grants capabilities to a non-root container only in the bounding set, not the effective set, and bind() checks the effective set. A non-root process running a binary without file capabilities gets an empty effective set, full stop. So the answer is boring: run on an unprivileged port inside.
|
||||
|
||||
```yaml
|
||||
app:
|
||||
user: "1000:1000"
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges]
|
||||
tor:
|
||||
user: "100:101"
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges]
|
||||
```
|
||||
|
||||
One line on the tor side remaps the visit:
|
||||
|
||||
```
|
||||
HiddenServicePort 80 app:8080
|
||||
```
|
||||
|
||||
People still land on port 80 of the onion address. Only the internal port moved.
|
||||
|
||||
### Healthchecks that check the right thing
|
||||
|
||||
The tor image ships with a healthcheck that probes the SOCKS port. I had just turned SOCKS off, so healthy became unhealthy forever. Override it with the question you actually mean: is the process alive?
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "pgrep", "-x", "tor"]
|
||||
```
|
||||
|
||||
The nginx trap was subtler. My first version used `wget --spider` against the root path. The site returned 404 (I hadn't uploaded content yet), wget exited non-zero, and the container got marked unhealthy. The check was testing the content, not the service. `nc -z 127.0.0.1 80` tests the port and nothing else.
|
||||
|
||||
One more thing: tor resolves its `HiddenServicePort` target at startup. If the app container isn't up yet, tor dies with "Unparseable address in hidden service port configuration" and crash-loops. I'd watched four failed starts in the old logs, unnoticed, because nothing was watching. `depends_on` in the compose file fixed the ordering.
|
||||
|
||||
### The ownership landmine
|
||||
|
||||
After generating new keys I copied the key material back through a file-share mount. The next containers crash-looped:
|
||||
|
||||
```
|
||||
[warn] Could not open "/var/lib/tor/.../hs_ed25519_secret_key": Permission denied
|
||||
```
|
||||
|
||||
Files written through the share are owned by the share user, not by the uid tor runs as. The fix is one command, runnable anywhere, including a throwaway container with the volume attached:
|
||||
|
||||
```bash
|
||||
docker run --rm -v /path/libTor:/var/lib/tor alpine \
|
||||
sh -c "chown -R 100:101 /var/lib/tor && chmod -R 700 /var/lib/tor"
|
||||
```
|
||||
|
||||
### Verify, don't believe
|
||||
|
||||
Every fix above ends with a test I can run myself. The egress one is my favorite, because the two probes together are convincing: the same wget that fails inside the app's network succeeds from tor's egress network.
|
||||
|
||||
```bash
|
||||
# inside the app: with internal:true, even DNS should fail
|
||||
wget -T 6 -qO- http://ipv4.icanhazip.com # → "wget: bad address", exit 1
|
||||
|
||||
# same probe, on tor's egress network, as a control
|
||||
wget -T 6 -qO- http://ipv4.icanhazip.com # → <your IP>, exit 0
|
||||
```
|
||||
|
||||
If you can't exec into a container, attach a throwaway probe container to the same network. It tests the network's properties, which is the thing you actually hardened.
|
||||
|
||||
## Would I offer this as a service?
|
||||
|
||||
I keep thinking about this, because the marginal cost is close to zero: the tor containers and their isolation are already running.
|
||||
|
||||
Most of my hosting clients want the opposite of an onion service. They want to be found on Google. Selling someone a website that only opens in Tor Browser means selling them secrecy they probably don't need, and it means supporting their visitors through installing Tor Browser.
|
||||
|
||||
But there is a real sliver of a market. Lawyers exchanging drafts, auditors, people delivering digital goods, anyone sharing an archive that should never show up in a search index. For those clients the pitch writes itself: no port forwards, no domain, no logs on some platform you don't control, just an address you physically hand to the people who should have it.
|
||||
|
||||
There's one trick that sells better than I expected, and it ties to a question everyone asks: can you choose how the address starts? v3 onion addresses are random, but only because the keys are. You can mine them: generate keypairs until the base32 address begins with the prefix you want. Every character costs a factor of 32 in work. Community mining tools on a modern GPU check addresses in the low millions per second, which makes an 8-character prefix a day-or-a-few-days job, 9 characters a patient weeks-long one, and 10 characters a serious multi-GPU commitment. A prefix that starts with the client's brand turns an unmemorable 56-character string into something they can verify is really yours, and in a niche where trust is the entire product, that's real value. I'd mine 8 happily, 9 for a paying client, and quote 10 with a straight face only if they're renting the GPUs.
|
||||
|
||||
So: as a bolt-on for a handful of specific clients, yes. As a product line, no. The market is too thin to build a funnel on, and the support burden doesn't shrink with volume. Privacy consulting with an onion attached, fine. Onion hosting as a web hosting tier, someone else's problem.
|
||||
|
||||
## What stuck
|
||||
|
||||
- **Run the tests, not the config file.** In-container probes and health states are the truth; the yaml is the intention.
|
||||
- **Prefer mechanisms that can't silently unwind.** `internal: true` beats an iptables boot task every time.
|
||||
- **Healthchecks are cheap. They caught a crash loop in minutes** where previously nothing watched for days.
|
||||
- **The boring host firewall matters more than exotic Tor hardening.** Nobody de-anonymizes you with traffic analysis if they can just walk in through an open port.
|
||||
|
||||
## Want one of these?
|
||||
|
||||
I build and run these for clients: creating an onion site, hosting and maintaining it (updates, healthchecks, monitoring), and mining branded addresses when a client wants a name instead of gobbledygook. If you need a site that exists only for the people you choose, this is exactly what it does.
|
||||
|
||||
- WhatsApp: [wa.me/60127972969](https://wa.me/60127972969)
|
||||
- Email: [[email protected]](mailto:[email protected]?subject=Onion%20service%20setup)
|
||||
- What else I do: [hoelee.com](https://hoelee.com)
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
title: "Hello, world — about this blog"
|
||||
title: "Hello, World — About This Blog"
|
||||
description: "What this blog is for: technical writing, self-hosting experience, and a record of what I build and learn."
|
||||
pubDate: 2026-09-06
|
||||
pubDate: 2026-09-05
|
||||
category: notes
|
||||
tags: [intro]
|
||||
ogImage: /og/hello-world.png
|
||||
banner: /banners/hello-world.png
|
||||
---
|
||||
|
||||
Welcome. This is where I write about what I build and learn — mostly
|
||||
@@ -22,11 +24,19 @@ Most posts will be one of a few shapes:
|
||||
- **Tutorials** — a hard problem, what I tried, and the fix.
|
||||
- **Gotchas & notes** — short entries on the small things that cost me a day.
|
||||
|
||||
## What I write about
|
||||
|
||||
- **Full-stack engineering** — PHP/CodeIgniter, Java/Spring, TypeScript/React, WordPress.
|
||||
- **DevOps & self-hosting** — Docker, Traefik, Cloudflare tunnels, NAS/unRaid homelabs.
|
||||
- **AI & automation** — Telegram bots, n8n workflows, local LLMs, memory systems.
|
||||
- **Web3** — Solidity, Foundry, ERC-20/721 (learning, honestly framed).
|
||||
|
||||
## About me
|
||||
|
||||
I'm Lee Teong Hoe (Mr Hoelee), a full-stack developer and DevOps engineer
|
||||
based in Malaysia. I build web applications, self-host a ~140-container
|
||||
homelab, and run an email-hosting business for Malaysian SMEs.
|
||||
homelab, and run Hoelee Enterprise — a website design & development business
|
||||
for Malaysian SMEs (with email hosting as a side offering, not the focus).
|
||||
|
||||
If you're a recruiter, a client, or a fellow builder — the posts here are my
|
||||
living portfolio. Start with the [latest posts](/posts/).
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
title: "How I Built the DigiKedai Telegram AI Bot"
|
||||
description: "A customer-support AI bot that answers questions and provisions free-trial accounts over Telegram and WhatsApp — TypeScript, grammY, a Cloudflare tunnel webhook, and a self-hosted LiteLLM gateway."
|
||||
pubDate: 2026-09-06
|
||||
category: case-studies
|
||||
tags: [telegram, docker, cloudflare, litellm, typescript, ai]
|
||||
ogImage: /og/how-i-built-the-digikedai-telegram-bot.png
|
||||
banner: /banners/how-i-built-the-digikedai-telegram-bot.png
|
||||
---
|
||||
|
||||
Digi Kedai sells digital products — online courses, ebooks, and templates — and
|
||||
every sale involves a stream of the same customer questions: *"does this course
|
||||
have a free preview?", "how do I get my account?", "which package should I
|
||||
buy?"*. Answering them manually doesn't scale for a one-person business. So I
|
||||
built an AI customer-support bot that answers those questions around the clock
|
||||
and can even hand out free-trial accounts without a human in the loop.
|
||||
|
||||
This is the story of how it went together — the architecture, the LLM wiring,
|
||||
and the bugs that ate an afternoon each.
|
||||
|
||||
## Why a business needs a bot like this
|
||||
|
||||
Before the architecture, the *why*. A support bot isn't a gimmick — it changes
|
||||
the economics of a small business:
|
||||
|
||||
- **Saves money on staff.** Every routine question a bot answers is one your
|
||||
team doesn't have to. Digi Kedai gets the equivalent of a round-the-clock
|
||||
support agent for a fraction of the cost of hiring one.
|
||||
- **Answers instantly, 24/7.** Customers ask at midnight and on weekends. A bot
|
||||
replies in seconds, in their language — no queues, no "we'll get back to you".
|
||||
- **Converts browsers into buyers.** The bot doesn't just answer — it *upsells*.
|
||||
A "does this have a free trial?" question turns into a claimed trial account
|
||||
in a couple of taps, with no human in the loop.
|
||||
- **Never forgets a customer.** Long-term memory means repeat customers are
|
||||
greeted like regulars, not strangers.
|
||||
- **Scales with your catalogue.** Add a product and the bot already knows it —
|
||||
no retraining, no new FAQ pages.
|
||||
|
||||
For a one-person business like Digi Kedai, that's the difference between losing
|
||||
sales at 2 a.m. and closing them.
|
||||
|
||||
## The stack
|
||||
|
||||
- **TypeScript + Node 20** running as a single container on my Synology NAS.
|
||||
- **grammY** — a lightweight Telegram Bot framework, wired in webhook mode.
|
||||
- **Hono** — a tiny HTTP server that receives webhooks and answers `/health`.
|
||||
- **Cloudflare tunnel** — `bot.digikedai.com` routes straight into the container;
|
||||
no public IP, no open ports.
|
||||
- **LiteLLM** — a self-hosted gateway in front of the actual model, so I can swap
|
||||
providers or add fallbacks without touching bot code.
|
||||
- **PostgreSQL** — conversation and user-memory storage, shared with my mem0 stack.
|
||||
|
||||
## The architecture
|
||||
|
||||
```
|
||||
Customer → Telegram/WhatsApp → Cloudflare tunnel → bot.digikedai.com
|
||||
→ Hono POST /<secret>/webhook → grammY handler
|
||||
→ save to PostgreSQL → Agent.respond (system prompt + retrieved catalog facts + last 20 msgs)
|
||||
→ LiteLLM (model alias "mem0-openai") → persist + reply
|
||||
```
|
||||
|
||||
A few decisions worth explaining:
|
||||
|
||||
**A Cloudflare tunnel into the container, with no Traefik hop.** My homelab is
|
||||
behind CGNAT, so nothing is publicly reachable without a tunnel. The webhook URL
|
||||
lives behind a **secret path segment** (`/<secret>/webhook`) so Telegram's
|
||||
updates only land if the caller knows the secret — a cheap first line of defense
|
||||
on top of Telegram's own token auth.
|
||||
|
||||
**The model is addressed by a LiteLLM *alias*, never a raw model name.** The bot
|
||||
calls `mem0-openai`; LiteLLM maps that to the real model (with an OpenRouter
|
||||
fallback behind it). The bot never needs to know which provider is actually
|
||||
serving the request. I set `temperature: 0.4` and a `45s` timeout.
|
||||
|
||||
**The bot knows its catalogue without prices or internal paths.** A build-time
|
||||
generator turns a single `catalog_sku.csv` (the source of truth) into a
|
||||
TypeScript module the bot imports — SKU, name, category, size, and a product URL,
|
||||
and *nothing else*. Prices are never baked in ("check the site for the current
|
||||
price"), and internal resource paths never ship to the bot image. That keeps the
|
||||
bundle lean and prevents the model from leaking internal structure.
|
||||
|
||||
## Retrieval: how the bot actually *knows* the catalogue
|
||||
|
||||
A generic LLM with the catalogue stuffed into a prompt would hallucinate. So the
|
||||
bot retrieves first, then answers. It has a three-tier retriever:
|
||||
|
||||
1. **SKU token match** — `/\b[A-Z]{2,}\d{2,}\b/gi` catches exact SKUs like `CZH01`.
|
||||
2. **Whole-query substring** — for short, precise queries.
|
||||
3. **Per-segment matching** — splits CJK runs (stripping question particles like
|
||||
有/吗/哪些) and filters English stopwords.
|
||||
|
||||
The top-5 matches become the "facts" injected into the system prompt, and the
|
||||
model is instructed to answer *only* from what it actually retrieved — and to say
|
||||
so when it finds nothing.
|
||||
|
||||
This matters more than it looks. A customer asking *"does CZH01 have a free
|
||||
version?"* gets a correct answer about `FREECZH01` only because the retriever,
|
||||
on hitting the paid SKU, **automatically attaches its free twin** and ranks it
|
||||
second. That one detail turns a "no, sorry" into the correct upsell.
|
||||
|
||||
## Free-trial provisioning — no human in the loop
|
||||
|
||||
The part I'm proudest of: the bot doesn't just *talk about* free trials, it
|
||||
*issues* them. A customer can trigger it three ways — the `/trial` command, a
|
||||
natural-language message like *"我要这个试用:digikedai.com/products/czh01"*, or a
|
||||
deep link like `?start=CZH01`.
|
||||
|
||||
Rather than route through n8n, the bot writes **directly to NocoDB** — inserting
|
||||
a customer row and a customer-product row. My existing webhooks pick that up and
|
||||
provision the actual account automatically, so the bot never touches the file
|
||||
server. The whole thing is idempotent: if a customer already has an account, it
|
||||
reuses it instead of creating a duplicate; if the product insert fails, it rolls
|
||||
back the customer row so a retry doesn't dead-end.
|
||||
|
||||
There's even a multi-account selector. If the same Telegram user has several
|
||||
accounts, the bot lists them as inline buttons and asks which one to attach the
|
||||
trial to.
|
||||
|
||||
## The bugs that ate an afternoon each
|
||||
|
||||
Shipping this was not smooth. Three debugging stories stand out:
|
||||
|
||||
**1. The infinite reply loop.** Telegram re-delivers a webhook update if the
|
||||
server doesn't acknowledge it within a timeout window. My handler was running
|
||||
long (LLM latency), so Telegram re-sent the same message — and the bot answered
|
||||
it again, and again. The fix was `onTimeout: "return"` with a 50-second window,
|
||||
which stops the redelivery spiral dead.
|
||||
|
||||
**2. The model alias 400.** Calling the raw model name (`gpt-5-mini`) returned a
|
||||
400. Only the LiteLLM alias worked. This is now a hard rule in the repo: *the LLM
|
||||
must be the alias, never the raw upstream name.*
|
||||
|
||||
**3. The WhatsApp "m_text" bug.** When I added a WhatsApp channel (via a browser
|
||||
extension that POSTs WhatsApp Web events to a webhook), a "customize webhook
|
||||
payload" setting with empty-string template values made the extension send the
|
||||
literal field *name* `m_text` as the message text — so the bot replied *"I don't
|
||||
understand m_text"*. The fix was to turn that setting off and use the default
|
||||
payload, which the adapter reads correctly.
|
||||
|
||||
The honest takeaway from all three: the failures weren't in the hard parts — the
|
||||
LLM or the retrieval. They were in **webhook lifecycle and payload-contract
|
||||
details**, the boring edges where integrations actually break.
|
||||
|
||||
## Deterministic flows on top of the LLM
|
||||
|
||||
An LLM is great for open-ended questions and terrible for *state*. So the
|
||||
conversational pieces that need reliability — buying, trial redemption, deep
|
||||
links — are **deterministic state machines**, not prompt engineering:
|
||||
|
||||
- **Buy intent** (`order`, `want to buy`, `buy`…) is intercepted before the LLM
|
||||
and drives an inline keyboard (online store → admin → back), never a free-form
|
||||
reply.
|
||||
- **Deep links** carry the SKU in a `?start=` payload, but Telegram only allows
|
||||
`A-Z a-z 0-9 _ -` there — a `:` silently breaks it. I learned that the hard way
|
||||
and switched the separator from `buy:SKU` to `buy-SKU`.
|
||||
- **Off-topic messages** in WhatsApp make the model emit a sentinel (`NO_REPLY`)
|
||||
that the agent turns into *"send nothing"* — so the bot stays silent instead of
|
||||
babbling when it has nothing useful to say.
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
- **Add `setMyCommands()` at startup.** The command list is defined in code, but
|
||||
I never registered it with Telegram, so the in-chat command menu was empty
|
||||
until I fixed it via the Bot API. Small, but it cost a real customer touchpoint.
|
||||
- **Design the payload contract before the channel adapter.** The WhatsApp
|
||||
`m_text` bug came from trusting a customization feature I hadn't read the
|
||||
contract for.
|
||||
- **Treat webhook timeouts as first-class architecture**, not an afterthought —
|
||||
the redelivery loop was avoidable if I'd thought about acknowledgement windows
|
||||
on day one.
|
||||
|
||||
## The result
|
||||
|
||||
One container on the NAS now answers customer questions across two channels
|
||||
(Telegram + WhatsApp), retrieves the correct catalogue entry by SKU or by natural
|
||||
language, and issues free-trial accounts end-to-end — with a prompt suite of
|
||||
~175 unit tests covering the deterministic flows, retrieval, and the payload
|
||||
contracts. The source lives at
|
||||
[git.hoelee.com/hoelee/digikedai-bot](https://git.hoelee.com/hoelee/digikedai-bot).
|
||||
|
||||
If you're building your own Telegram bot backed by an LLM, the lesson is the
|
||||
boring one: the model is the easy part. The webhook lifecycle, the payload
|
||||
contract, and the idempotency of your provisioning are where it actually breaks —
|
||||
design those first.
|
||||
|
||||
---
|
||||
|
||||
## Want a bot like this for your business?
|
||||
|
||||
I build custom Telegram/WhatsApp AI bots, websites, and self-hosted
|
||||
infrastructure for businesses. If a bot like this could save you time and
|
||||
money — or you'd like to hire me — I'd love to talk:
|
||||
|
||||
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
|
||||
- 📧 **Email:** [[email protected]](mailto:[email protected])
|
||||
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
title: "How I host this blog: Astro, Gitea Actions, and self-hosted CI/CD"
|
||||
title: "How I Host This Blog: Astro, Gitea Actions, and Self-Hosted CI/CD"
|
||||
description: "A walkthrough of the end-to-end pipeline that builds and serves this site — git-as-CMS, a self-hosted runner on my unRaid server, and Cloudflare in front."
|
||||
pubDate: 2026-09-06
|
||||
pubDate: 2026-09-04
|
||||
category: case-studies
|
||||
tags: [astro, gitea, ci-cd, self-hosting, docker, cloudflare]
|
||||
ogImage: /og/how-i-host-this-blog.png
|
||||
banner: /banners/how-i-host-this-blog.png
|
||||
---
|
||||
|
||||
This blog is itself a project I built to demonstrate the kind of work I do.
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
---
|
||||
title: "How I Made My Own Songs with Suno AI"
|
||||
description: "I wrote the lyrics, tuned the prompts, and shipped seven songs with Suno AI — folk ballads, a kids' tune, an anime-style Heart Sutra. Full workflow + the tracks."
|
||||
pubDate: 2026-09-11
|
||||
category: case-studies
|
||||
tags: [suno, ai-music, music, lyrics, prompting, ai]
|
||||
ogImage: /og/how-i-made-my-own-songs-with-suno-ai.png
|
||||
banner: /banners/how-i-made-my-own-songs-with-suno-ai.png
|
||||
---
|
||||
|
||||
I make my own songs. Not by playing an instrument — by writing lyrics,
|
||||
crafting style prompts, and letting Suno AI produce the voice, the
|
||||
arrangement, and the mix. This post is the full workflow: how the current
|
||||
model family works, what I actually type into the prompt boxes, and seven
|
||||
finished tracks you can play right here.
|
||||
|
||||
## Why make your own music with AI?
|
||||
|
||||
Two reasons, one personal and one practical.
|
||||
|
||||
**Personally:** I like experimenting with creative AI. A song is a tight,
|
||||
self-contained project — lyrics, style, structure, performance — that
|
||||
exercises the same prompt-crafting muscles as building a bot or wiring a
|
||||
pipeline. And the output is shareable in a way a config file never is.
|
||||
|
||||
**Practically:** this is now a client skill. Small businesses need jingles,
|
||||
background music for videos, event music, and branded audio — without a
|
||||
studio budget. If I can deliver a usable track from a one-page brief,
|
||||
that's a service, not a party trick. Suno's current **v6** models (released
|
||||
Sep 9, 2026) made this dramatically more controllable than the V3-era
|
||||
version I started with in April 2024.
|
||||
|
||||
## The current standard: what Suno's models look like in 2026
|
||||
|
||||
Version confusion is the first thing to clear up, because old guides
|
||||
online reference models that don't exist anymore:
|
||||
|
||||
| Model | When | Who gets it |
|
||||
|---|---|---|
|
||||
| **v6 / v6-wild** | Sep 9, 2026 | Paid plans (flagship + experimental) |
|
||||
| **v6-mini** | Sep 9, 2026 | Everyone — fast, efficient, great for drafts |
|
||||
| **v5.5** | Mar 26, 2026 | Paid — added Voices, Custom Models, My Taste |
|
||||
| **v4.5-all** | Oct 21, 2025 | Free plan (no commercial rights, attribution required) |
|
||||
| V4 / V4.5 / V5 | 2024–2026 | Paid plans, superseded |
|
||||
|
||||
The v6 family added the control features that changed how I work:
|
||||
|
||||
- **Plain-language section edits** — "change the chorus so it's sung by a
|
||||
gospel choir" edits one section without regenerating the song.
|
||||
- **Single lyric swaps** — "change 'love' to 'light'" updates one line.
|
||||
- **Mashups** — combine vocals from one song, drums from another, new
|
||||
lyrics, in one request.
|
||||
- **Sample → isolate → rebuild** — pull a riff at 0:45, isolate the
|
||||
guitar, build a beat around it.
|
||||
- **Multimodal input** — start from text, audio, an image, or even a video.
|
||||
|
||||
My own tracks below were made with the earlier V4-era workflow (April
|
||||
2025). The fundamentals — style field, lyric metatags, iteration — are
|
||||
unchanged, and I note where v6 would have saved me steps.
|
||||
|
||||
## My workflow: lyrics first, style second
|
||||
|
||||
The order matters more than people think. Most beginners open Suno, paste
|
||||
a genre into Simple Mode, and get a generic song. I do the opposite:
|
||||
|
||||
1. **Write the song as a document first** — title, structure, lyrics. The
|
||||
emotion and the story come from me; Suno supplies the performance.
|
||||
2. **Craft the style prompt** — genre + mood + era + instruments + vocal
|
||||
persona + production, often 200+ characters, describing the *journey*
|
||||
of the song, not just its genre.
|
||||
3. **Add metatags to the lyrics** — `[Verse]`, `[Chorus]`, `[Bridge]`,
|
||||
`[Whispered]`, `[Belted]`. Without these, Suno defaults to a flat
|
||||
verse-chorus-verse with no emotional arc.
|
||||
4. **Generate 3–5 takes** per song and keep the best. Repair via
|
||||
Extend/Reuse Prompt, and restate the genre in every extension because
|
||||
style drifts.
|
||||
5. **Iterate on pronunciation** — AI singers read phonetically, so I
|
||||
respell ("through" → "thru", hyphenate unusual syllables) and always
|
||||
test proper nouns in a short clip first.
|
||||
|
||||
The worked example below shows exactly what this looks like.
|
||||
|
||||
## The songs
|
||||
|
||||
Seven tracks, hosted on my own media subdomain (`content.hoelee.com` via
|
||||
Publitio) and embedded with plain HTML5 audio. Press play:
|
||||
|
||||
<style>
|
||||
.suno-track{margin:2.2rem 0}
|
||||
.suno-track audio{width:100%;max-width:560px}
|
||||
.suno-box{max-height:190px;overflow-y:auto;border:1px solid var(--border,#3a3f47);border-radius:8px;padding:.7rem 1rem;margin-top:.6rem;font-size:.95em;line-height:1.6}
|
||||
.suno-box p{margin:.18rem 0;opacity:.55}
|
||||
.suno-box p.on{opacity:1;font-weight:600;color:var(--accent,#22c55e)}
|
||||
</style>
|
||||
|
||||
### 1. 轮回的渡船 — The Ferry of Reincarnation
|
||||
|
||||
Chinese folk ballad (古风民谣). The ferryman of the River of Forgetfulness,
|
||||
Meng Po's soup gone cold, a soul that refuses to reboard the wheel of
|
||||
rebirth. Written as a mythic love story about choosing someone across
|
||||
lifetimes. Starts hushed over sparse strings and builds to a full,
|
||||
anguished chorus.
|
||||
|
||||
<figure class="suno-track">
|
||||
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-samsara" src="https://content.hoelee.com/file/hoelee/music/%E8%BD%AE%E5%9B%9E%E7%9A%84%E6%B8%A1%E8%88%B9.mp3"></audio>
|
||||
<pre id="lrc-samsara" hidden>[00:12]河岸的雾漫过第三千个秋
|
||||
[00:18]我的桨声碎在无人渡口
|
||||
[00:26]忘川水打湿褪色的袖
|
||||
[00:30]你背影是前世未燃尽的篝火
|
||||
[00:33]佛说众生如萍聚散无由
|
||||
[00:39]我却数遍每一颗星斗
|
||||
[00:46]等残月照亮你回眸——
|
||||
[00:53]偏偏人间雪,落满我舟头
|
||||
[01:04]我摇着轮回的船,载不动红尘重如山
|
||||
[01:10]孟婆的汤冷了又添,你宁化涟漪不成全
|
||||
[01:20]若执念是穿心的箭,刺透因果的链
|
||||
[01:27]我愿在彼岸花凋谢前 再为你搁浅
|
||||
[01:34]那夜你魂在风里轻轻叹
|
||||
[01:40]掌心符咒烫穿我掌纹的茧
|
||||
[01:46]奈何桥断成半截诗篇
|
||||
[01:53]我偷改命簿只换你半句谎言
|
||||
[02:00]你吻过的铜铃锈在桅杆
|
||||
[02:07]风一吹响了三生冬夏
|
||||
[02:14]余生的河灯,照不亮对岸
|
||||
[02:20]我摇着轮回的船,渡不完哀愁的深浅
|
||||
[02:27]你眼泪凝成琥珀的盐,埋进我骨骼作谶言
|
||||
[02:33]若爱是焚不尽的经卷,灰烬里写永远
|
||||
[02:41]我跪在忘川最痛的流域,求一次擦肩
|
||||
[03:00]梵音绕啊绕啊绕不过执念
|
||||
[03:06]佛珠断啊断啊断在你眉间
|
||||
[03:14]船沉时,天地裂开一道缝
|
||||
[03:20]来世你为青山,我为雪
|
||||
[03:27]轮回的渡船,碎成烟
|
||||
[03:34]你是我 永世不靠岸的劫</pre>
|
||||
</figure>
|
||||
|
||||
### 2. Happy Way to School
|
||||
|
||||
English children's song — the original prompt called for a "fizzy,
|
||||
orange-soda indie folk" sound: acoustic guitar, playful whistling, and
|
||||
bright kid-friendly vocals. It's the walk-to-school ritual turned into a
|
||||
tiny anthem: ponytails bouncing, kittens at the café, friends in step.
|
||||
|
||||
<figure class="suno-track">
|
||||
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-school" src="https://content.hoelee.com/file/hoelee/music/Happy-Way-to-School.mp3"></audio>
|
||||
<pre id="lrc-school" hidden>[00:07.00]A little girl with a heart so bright,
|
||||
[00:12.00]Skips down the street in morning light,
|
||||
[00:16.00]Her smile's like sunshine in the air,
|
||||
[00:20.00]A joyful moment everywhere.
|
||||
[00:24.00]The café's window's full of cheer,
|
||||
[00:28.00]Curious kittens drawing near,
|
||||
[00:32.00]Their eyes meet hers, a happy sight,
|
||||
[00:35.00]They wave her off in pure delight.
|
||||
[00:39.00]Hand in hand with friends so sweet,
|
||||
[00:43.00]They skip along the lively street,
|
||||
[00:47.00]Ponytails bounce with every move,
|
||||
[00:51.00]The world feels warm, a groove to prove.
|
||||
[00:55.00]School is waiting, full of dreams,
|
||||
[00:59.00]A place where friendship always beams,
|
||||
[01:02.00]With every step, they laugh and play,
|
||||
[01:08.00]The start of a beautiful day.
|
||||
[01:17.00]Together they walk, hearts in sync,
|
||||
[01:21.00]The world so bright, as if they think,
|
||||
[01:25.00]No better way to face the day,
|
||||
[01:28.00]In friendship's glow, they'll always stay.
|
||||
[01:32.00]A happy way to school they go,
|
||||
[01:35.00]Through the morning's gentle glow,
|
||||
[01:38.00]With every step, the bond is clear,
|
||||
[01:43.00]They'll cherish this moment year by year.</pre>
|
||||
</figure>
|
||||
|
||||
### 3. 新靓 — Heart Sutra, Meditative Beats
|
||||
|
||||
The 般若波罗蜜多心经 (Heart Sutra) chanted over rhythmic, meditative
|
||||
production — the brief was "meditation depth + groove energy". 观自在菩萨
|
||||
opening, the full 色不異空 passage, ending on 揭諦揭諦. An attempt to make
|
||||
ancient scripture something you can actually move to.
|
||||
|
||||
<figure class="suno-track">
|
||||
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-xinliang" src="https://content.hoelee.com/file/hoelee/music/%E6%96%B0%E9%9D%93.mp3"></audio>
|
||||
<pre id="lrc-xinliang" hidden>[00:00.00]拥凝练韵文引导心灵净化,
|
||||
[00:06.43]在跃动节拍与禅意氛围间形成张力,
|
||||
[00:12.87]达成冥想深度与律动活力的独特平衡随至…
|
||||
[00:19.31]如梦如幻… 心境无常…
|
||||
[00:25.74]众生皆空,见空即见真!
|
||||
[00:32.18]觀自在菩薩,行深般若波羅密多時,
|
||||
[00:38.62]照見五蘊皆空度一切苦厄,
|
||||
[00:45.06]色不異空,空不異色,
|
||||
[00:51.49]即是空,空即是色,
|
||||
[00:57.93]受想行識亦復如是。
|
||||
[01:04.37]色不異空,空不異色,
|
||||
[01:10.80]受想行識亦復如是,
|
||||
[01:17.24]般若波羅密多,無所畏懼,
|
||||
[01:23.68]心無罣礙,涅槃即是此。
|
||||
[01:30.12]舍利子,諸法空相,
|
||||
[01:36.55]不生不滅,不垢不淨,
|
||||
[01:42.99]無增無減,無老死,
|
||||
[01:49.43]無苦集滅道,無智亦無得。
|
||||
[01:55.87]色不異空,空不異色,
|
||||
[02:02.30]受想行識亦復如是,
|
||||
[02:08.74]般若波羅密多,無所畏懼,
|
||||
[02:15.18]心無罣礙,涅槃即是此。
|
||||
[02:21.61]三世諸佛,依般若波羅密多,
|
||||
[02:28.05]得阿耨多羅三藐三菩提,
|
||||
[02:34.49]般若波羅密多!
|
||||
[02:40.93]是大神咒,是真實不虛!
|
||||
[02:47.36]揭諦揭諦,波羅揭諦,
|
||||
[02:53.80]波羅僧揭諦,菩提薩婆訶,
|
||||
[03:00.24]心無罣礙,遠離顛倒夢想!</pre>
|
||||
</figure>
|
||||
|
||||
### 4. 新劲 — Fresh Energy
|
||||
|
||||
A harder-edged companion to 新靓 — same meditative lineage, more percussive
|
||||
drive. No lyric sheet on this one; it lives as a vibe piece.
|
||||
|
||||
<figure class="suno-track">
|
||||
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" src="https://content.hoelee.com/file/hoelee/music/%E6%96%B0%E5%8A%B2.mp3"></audio>
|
||||
</figure>
|
||||
|
||||
### 5. 泡泡星球漫游记 — Bubble Planet Adventure
|
||||
|
||||
Kids' indie folk, and the one with the most documented backstory — the
|
||||
full original brief, style prompt, and lyric sheet are in the worked
|
||||
example below. Bubble planets, candy fountains, a galactic playground.
|
||||
|
||||
<figure class="suno-track">
|
||||
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" data-lrc="lrc-bubble" src="https://content.hoelee.com/file/hoelee/music/%E6%B3%A1%E6%B3%A1%E6%98%9F%E7%90%83%E6%BC%AB%E6%B8%B8%E8%AE%B0.mp3"></audio>
|
||||
<pre id="lrc-bubble" hidden>[00:09.00]彩云兜着阳光转圈圈
|
||||
[00:15.00]贝壳装满彩虹的碎片
|
||||
[00:19.00]跳进橘子汽水的夏天
|
||||
[00:24.00]海鸥掠过浪花的琴键
|
||||
[00:48.00]泡泡载着梦飞向屋檐 (飞呀飞呀)
|
||||
[00:53.00]尾巴挂着星星的秋千 (晃呀晃呀)
|
||||
[00:57.00]鲸鱼喷出糖果的喷泉 (甜到脚尖)
|
||||
[01:02.00]魔法地图画满冒险线 (转个圈圈)
|
||||
[01:08.00]橡皮艇划开银河水面
|
||||
[01:12.00]萤火虫点亮薄荷月圆
|
||||
[01:17.00]棉花糖云朵蓬松柔软
|
||||
[01:20.00]流星滑梯通向我窗前
|
||||
[01:27.00]泡泡载着梦飞向屋檐 (飞呀飞呀)
|
||||
[01:31.00]尾巴挂着星星的秋千 (晃呀晃呀)
|
||||
[01:35.00]鲸鱼喷出糖果的喷泉 (甜到脚尖)
|
||||
[01:40.00]魔法地图画满冒险线 (转个圈圈)
|
||||
[02:05.00]水晶风筝追着蝴蝶结
|
||||
[02:11.00]跳跳糖在舌尖开派对
|
||||
[02:14.00]彩虹滑板穿过梧桐叶
|
||||
[02:20.00]月亮船摇着光的湖水
|
||||
[02:24.00]每个气泡都是新世界
|
||||
[02:29.00]装得下所有奇妙遇见
|
||||
[02:34.00]在泡泡星球蹦跳向前
|
||||
[02:38.00]明天又是崭新的乐园</pre>
|
||||
</figure>
|
||||
|
||||
### 6. 日漫心经 — Heart Sutra, Anime Style
|
||||
|
||||
The same classical text, reimagined with an anime-inspired sound palette —
|
||||
big chorus energy, dramatic key lifts, the emotional grammar of
|
||||
contemporary J-pop openings.
|
||||
|
||||
<figure class="suno-track">
|
||||
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" src="https://content.hoelee.com/file/hoelee/music/%E6%97%A5%E6%BC%AB%E5%BF%83%E7%BB%8F.mp3"></audio>
|
||||
</figure>
|
||||
|
||||
### 7. 未接来电 Unread Heartbeats (v2)
|
||||
|
||||
A mandopop ballad about that one missed call: the dial tone as a heartbeat,
|
||||
the read receipts that stay unread. v2 is the final master — tighter
|
||||
arrangement, cleaner vocal mix. (The original take stays in my archive.)
|
||||
|
||||
<figure class="suno-track">
|
||||
<audio controls preload="metadata" oncontextmenu="return false;" controlslist="nodownload" src="https://content.hoelee.com/file/hoelee/music/%E6%9C%AA%E6%8E%A5%E6%9D%A5%E7%94%B5-Unread-Heartbeats-v2.mp3"></audio>
|
||||
</figure>
|
||||
|
||||
<script>
|
||||
// suno-track: sync LRC lyrics with <audio> playback (no dependencies)
|
||||
document.querySelectorAll('audio[data-lrc]').forEach((audio) => {
|
||||
const raw = document.getElementById(audio.dataset.lrc).textContent;
|
||||
const lines = raw.split('\n').map((l) => {
|
||||
const m = l.match(/\[(\d+):(\d+)(?:\.(\d+))?\](.*)/);
|
||||
return m ? { t: +m[1] * 60 + +m[2] + (+(m[3] || '0')) / 100, txt: m[4] } : null;
|
||||
}).filter(Boolean);
|
||||
const box = document.createElement('div');
|
||||
box.className = 'suno-box';
|
||||
lines.forEach((l) => { const p = document.createElement('p'); p.textContent = l.txt; box.appendChild(p); });
|
||||
audio.parentNode.insertBefore(box, audio.nextSibling);
|
||||
let cur = -1;
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
const now = audio.currentTime;
|
||||
let i = lines.findIndex((l) => l.t > now) - 1;
|
||||
if (i < 0) i = lines.length - 1;
|
||||
if (i !== cur) {
|
||||
if (cur >= 0) box.children[cur].classList.remove('on');
|
||||
cur = i;
|
||||
if (cur >= 0) { box.children[cur].classList.add('on'); box.children[cur].scrollIntoView({ block: 'nearest' }); }
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
## Worked example: Bubble Planet Adventure, from brief to track
|
||||
|
||||
This is the full chain for 泡泡星球漫游记 — the only one I kept every
|
||||
scrap of paperwork for. It shows the workflow above in concrete form.
|
||||
|
||||
**Step 1 — the concept brief (my notes, in Chinese):**
|
||||
|
||||
> 独立民谣(Indie Folk):木吉他+口哨声组合,适合突出童趣感
|
||||
> 人声选择「精灵少女」音色库,自带俏皮气声
|
||||
> 打击乐:玻璃瓶敲击音效呼应「橘子汽水」意象
|
||||
> 副歌加入反向混响(reverse reverb)增强魔法感
|
||||
|
||||
**Step 2 — the English style prompt (what actually went into Suno):**
|
||||
|
||||
```text
|
||||
Indie folk vibe with acoustic guitar and playful whistles, fizzy like
|
||||
orange soda, featuring sprite-girl vocals and clinking glass bottle
|
||||
beats, reverse reverb brings dreamlike magic
|
||||
```
|
||||
|
||||
Four decisions compressed into one line: the *instrument* (acoustic
|
||||
guitar + whistles), the *feel* (fizzy like orange soda), the *voice*
|
||||
(sprite-girl with airy delivery), and the *production trick* (reverse
|
||||
reverb on the chorus for the magic feel).
|
||||
|
||||
**Step 3 — the lyric sheet with metatags:**
|
||||
|
||||
```text
|
||||
[Intro][Whistling][Indie][Girl]
|
||||
彩云兜着阳光转圈圈
|
||||
贝壳装满彩虹的碎片
|
||||
跳进橘子汽水的夏天
|
||||
海鸥掠过浪花的琴键
|
||||
|
||||
[Chorus][Indie Folk][Reverse Reverb][Whistling]
|
||||
泡泡载着梦飞向屋檐(飞呀飞呀)
|
||||
尾巴挂着星星的秋千(晃呀晃呀)
|
||||
鲸鱼喷出糖果的喷泉(甜到脚尖)
|
||||
魔法地图画满冒险线(转个圈圈)
|
||||
|
||||
[Verse][Indie][Girl]
|
||||
橡皮艇划开银河水面
|
||||
萤火虫点亮薄荷月圆
|
||||
棉花糖云朵蓬松柔软
|
||||
流星滑梯通向我窗前
|
||||
|
||||
[Chorus]… (repeat)
|
||||
|
||||
[Interlude]
|
||||
|
||||
[Bridge][Fantasy Pop][Giggles]
|
||||
水晶风筝追着蝴蝶结
|
||||
跳跳糖在舌尖开派对
|
||||
彩虹滑板穿过梧桐叶
|
||||
月亮船摇着光的湖水
|
||||
|
||||
[Outro][Indie Folk][Girl]
|
||||
每个气泡都是新世界
|
||||
装得下所有奇妙遇见
|
||||
在泡泡星球蹦跳向前
|
||||
明天又是崭新的乐园
|
||||
|
||||
[Fade to End]
|
||||
```
|
||||
|
||||
Note what the metatags enforce that a bare lyric sheet wouldn't:
|
||||
section *labels* (`[Bridge]`, `[Outro]`), *delivery* (`[Whistling]`,
|
||||
`[Giggles]`), a *production change* mid-song (`[Reverse Reverb]` only on
|
||||
choruses), and a stylistic shift in the bridge (`[Fantasy Pop]`). The
|
||||
fill-in parentheticals — 飞呀飞呀, 晃呀晃呀 — tell the singer exactly
|
||||
how to ornament each line.
|
||||
|
||||
**Step 4 — generate, listen, repeat.** 3–5 takes per section, keep the
|
||||
best, extend the chorus that landed. The final track plays above.
|
||||
|
||||
## What I'd do differently with v6
|
||||
|
||||
If I made these today, four things change:
|
||||
|
||||
1. **Section edits instead of full rerolls.** 轮回的渡船's bridge took six
|
||||
generations to nail. With v6 I'd say *"make the bridge slower and
|
||||
sparser, just voice and a plucked guzheng"* and keep the rest intact.
|
||||
2. **Single-line lyric swaps.** The 忘川 couplet went through four wordings;
|
||||
v6 edits one line without rebuilding the song.
|
||||
3. **Voices / custom models.** v5.5's Voices would let me keep one
|
||||
consistent singer across all seven tracks instead of seven different
|
||||
AI vocalists.
|
||||
4. **Stems and Studio.** On the Premier plan, Studio (a browser DAW with
|
||||
multitrack + MIDI export) means fixing a vocal artifact instead of
|
||||
regenerating around it.
|
||||
|
||||
The craft layer — decent lyrics, an emotional arc, honest metatags —
|
||||
carries straight across model versions. The models get more controllable;
|
||||
the songwriting stays the job.
|
||||
|
||||
## The result
|
||||
|
||||
Seven original songs, written and produced with Suno AI, hosted on my own
|
||||
infrastructure (`content.hoelee.com`), embedded here with plain HTML5 audio
|
||||
and synced lyrics — no music-studio budget, no booking a vocalist, no
|
||||
session musician. The whole catalog lives in one folder on my NAS,
|
||||
versioned like any other project.
|
||||
|
||||
If you're curious about AI music, start with your own lyrics and an honest
|
||||
style sentence — genre, feel, voice, one production trick — then iterate
|
||||
until one take surprises you. That's the whole craft.
|
||||
|
||||
---
|
||||
|
||||
## Want original music — or AI-powered anything — for your business?
|
||||
|
||||
I build websites, Telegram/WhatsApp bots, and self-hosted infrastructure,
|
||||
and I produce original AI-assisted music for brands that want a jingle,
|
||||
background tracks, or event audio without a studio budget. There's no
|
||||
session musician to book and no licence maze — you get an original track
|
||||
you can actually use. If that sounds useful, I'd love to talk:
|
||||
|
||||
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
|
||||
- 📧 **Email:** [[email protected]](mailto:[email protected]?subject=Music%20for%20my%20business)
|
||||
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: "How I Vetted 20 VPS Providers in Three Hours with Parallel Subagents"
|
||||
description: "A case study in orchestrating parallel AI subagents to run due diligence on 20 hosting providers — WHOIS, AUP, and reputation checks — collapsing a multi-hour research task into a structured, verifiable vendor scorecard."
|
||||
pubDate: 2026-09-09
|
||||
category: case-studies
|
||||
tags: [ai-orchestration, subagents, due-diligence, hosting, devops]
|
||||
ogImage: /og/how-i-vetted-20-vps-providers-with-parallel-subagents.png
|
||||
banner: /banners/how-i-vetted-20-vps-providers-with-parallel-subagents.png
|
||||
---
|
||||
|
||||
## Why it matters
|
||||
|
||||
Choosing a hosting provider is a bet you place with a credit card and a DNS
|
||||
change. Get it wrong and "guaranteed uptime" becomes a suspension email at 2 AM,
|
||||
or "privacy-friendly" becomes a log-retention clause you never read.
|
||||
|
||||
The problem isn't a lack of information — it's that the information is scattered
|
||||
across a dozen pages per provider (WHOIS records, acceptable-use policies,
|
||||
privacy policies, pricing pages, and third-party review sites), and checking them
|
||||
manually is slow, boring, and error-prone. One provider is five tabs. Twenty
|
||||
providers is a hundred tabs and an afternoon you don't get back.
|
||||
|
||||
This is the story of how I collapsed that afternoon into about three hours — not
|
||||
by working faster, but by orchestrating a small team of AI subagents to run the
|
||||
boring part in parallel, and scoring everything against one checklist.
|
||||
|
||||
## The problem: verifying claims I couldn't take on faith
|
||||
|
||||
I needed to shortlist providers for a project with hard requirements: specific
|
||||
jurisdictions, payment methods, and traffic terms. None of those are written
|
||||
honestly on a homepage. They're written honestly in the boring documents — the
|
||||
WHOIS record that shows a domain is four months old, the AUP that quietly bans
|
||||
the exact service you wanted to run, the privacy policy that admits to log
|
||||
retention.
|
||||
|
||||
The hard part is that verifying one provider means reading five documents that
|
||||
disagree with each other. The marketing says "since 2012"; the WHOIS says "this
|
||||
April." The features page says "all traffic allowed"; the AUP says "Tor relays
|
||||
prohibited." One provider is a fact-checking exercise. Twenty is a research
|
||||
project.
|
||||
|
||||
## What I tried first: one agent, one big loop
|
||||
|
||||
My first instinct was the obvious one — a single assistant that works through the
|
||||
list, provider by provider, fetching each document, taking notes, moving on.
|
||||
|
||||
It worked. It was also the wrong tool for the shape of the job. The work is
|
||||
*embarrassingly parallel*: provider #7's WHOIS lookup has nothing to do with
|
||||
provider #3's privacy policy. Running them one after another meant the total time
|
||||
was the sum of every fetch, and — more importantly — the context window filled
|
||||
with half-finished notes from providers I'd already moved past. By provider
|
||||
eight or nine, early findings were getting crowded out by later ones.
|
||||
|
||||
The lesson: a task that's a flat loop over independent items isn't a reasoning
|
||||
problem, it's a fan-out problem. One long context is the wrong container for it.
|
||||
|
||||
## The fix: fan out with parallel subagents, then score once
|
||||
|
||||
The structure that worked was three layers:
|
||||
|
||||
**1. A checklist that doesn't care which provider it's pointed at.** Before
|
||||
spawning anything, I wrote down exactly what "verified" means per provider:
|
||||
|
||||
- domain registration date vs. the "since" claim
|
||||
- acceptable-use policy, searched for the specific service I cared about
|
||||
- privacy policy, read for the retention clause
|
||||
- third-party reputation (Trustpilot trend, not average; community mentions)
|
||||
- traffic terms ("unmetered" vs. a metered cap)
|
||||
|
||||
That checklist was the contract. Every subagent got the same one, plus a list of
|
||||
providers to run it against.
|
||||
|
||||
**2. Parallel subagents, one per batch of providers.** I split the pool into
|
||||
clusters and handed each cluster to its own subagent. Each one worked in
|
||||
isolation, with its own context and its own set of fetches, and returned a
|
||||
structured fact sheet per provider — not a paragraph, but fields I could drop
|
||||
straight into a scorecard.
|
||||
|
||||
The key here is that the subagents don't know about each other. That's the
|
||||
point: nothing from provider #1 has to share context space with provider #14.
|
||||
Each returns a self-contained result.
|
||||
|
||||
**3. A single scoring pass, done by me, not delegated.** The subagents produced
|
||||
findings; I did the judgment. The moment you let a subagent both *gather* the
|
||||
facts and *rank* the providers, you lose the audit trail — you get a verdict
|
||||
without the evidence behind it. Keeping scoring central means I can always say
|
||||
*why* something ranked where it did, and point at the exact WHOIS record or AUP
|
||||
line that drove it.
|
||||
|
||||
This mirrors a pattern I'd use for any code review or refactor: parallelize the
|
||||
mechanical collection, centralize the decisions.
|
||||
|
||||
### What the orchestration actually looked like
|
||||
|
||||
Roughly, per batch:
|
||||
|
||||
```text
|
||||
subagent → "here's the checklist, here are your 5 providers"
|
||||
→ per provider: fetch WHOIS, AUP, privacy policy, pricing, reviews
|
||||
→ return { domain_age, aup_flags[], retention, reputations, traffic }
|
||||
me → merge into one scorecard, apply the checklist, rank, write up
|
||||
```
|
||||
|
||||
Three subagents ran side by side. The whole pass — twenty providers, five
|
||||
documents each, one hundred-ish fetches — landed in the time it would have taken
|
||||
me to do two or three providers carefully by hand.
|
||||
|
||||
## What the verification actually caught
|
||||
|
||||
The scorecard surfaced real problems that a homepage never would have:
|
||||
|
||||
- **A provider whose "trusted since 2012" claim was younger than the domain.**
|
||||
WHOIS said the domain was registered that same year — a four-figure "years in
|
||||
business" claim on a domain months old. That's either a re-branded shell or a
|
||||
lie, and either way it downgraded every other claim on the page in my eyes.
|
||||
|
||||
- **Two providers whose AUP banned the exact service I wanted to run.** One
|
||||
listed "TOR nodes" and "anonymizing services" in its prohibited-activity
|
||||
clause; another banned "reverse proxies" and "tunnels." Both still advertised
|
||||
the opposite on their features pages. Ten minutes of `Ctrl-F` on the AUP is all
|
||||
it took to rule them out — but only once I *knew to check the AUP* instead of
|
||||
the features page.
|
||||
|
||||
- **A provider that looked cheap until I read the traffic terms.** "Unlimited" on
|
||||
a plan with a metered 1 TB cap is marketing. For a relay that both receives and
|
||||
forwards traffic, the real cost doubles — the "cheap" option wasn't.
|
||||
|
||||
The pattern across all of them: the disqualifying information was never hidden.
|
||||
It was *public*, sitting in a document the provider is legally required to
|
||||
publish. The skill isn't secret access — it's knowing which document to read and
|
||||
checking it against the marketing.
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
The subagent hand-off worked, but it was blunt. Next time I'd give each subagent
|
||||
the *exact* fields to return up front — a strict output schema — rather than a
|
||||
prose summary I then have to re-parse. Structured output means the scorecard is
|
||||
built by the time the last subagent returns, with no re-reading.
|
||||
|
||||
I'd also pin the "is this claim independently verifiable?" test earlier. Most of
|
||||
the red flags weren't a provider lying outright; they were a claim I couldn't
|
||||
check against any public record. Treat "unverifiable" as its own signal, and the
|
||||
shortlist shrinks fast.
|
||||
|
||||
## The result
|
||||
|
||||
~20 providers audited across ~100 document fetches, three subagents running in
|
||||
parallel, in the time a careful manual pass would have spent on two providers.
|
||||
Every ranking in the final scorecard traces back to a specific public record —
|
||||
a WHOIS date, an AUP line, a retention clause — not a vibe.
|
||||
|
||||
---
|
||||
|
||||
## Want this for your business?
|
||||
|
||||
Choosing a vendor is the same shape of problem whether it's a VPS, an API
|
||||
gateway, or a payroll provider: verify the claims you're relying on *before* you
|
||||
sign, against public records that can't be edited by marketing. If you've got a
|
||||
shortlist of vendors or tools and you want a structured, evidence-backed
|
||||
evaluation before you commit — I can run the due-diligence pass and hand you a
|
||||
scorecard, not a hunch.
|
||||
|
||||
[WhatsApp me](https://wa.me/60127972969) or [email me](mailto:[email protected]?subject=Vendor%20due-diligence%20evaluation) at hoelee.com — I help businesses pick the right infrastructure and build the automation around it.
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: "How to Verify a Hosting Provider Before You Buy"
|
||||
description: "A reusable checklist for separating real hosting claims from marketing: check the domain age against the 'since' claim, read the AUP for silent disqualifiers, and cross-check reputation on third-party sources."
|
||||
pubDate: 2026-09-09
|
||||
category: tutorials
|
||||
tags: [hosting, vps, due-diligence, whois, devops]
|
||||
ogImage: /og/how-to-verify-a-hosting-provider-before-you-buy.png
|
||||
banner: /banners/how-to-verify-a-hosting-provider-before-you-buy.png
|
||||
---
|
||||
|
||||
Every hosting provider's homepage is a list of promises: *"in business since 2005"*,
|
||||
*"guaranteed uptime"*, *"privacy-friendly"*, *"unlimited bandwidth"*. Some of it is
|
||||
true. A surprising amount of it isn't — and none of it is checked before you hand
|
||||
over a card number and point DNS at their servers.
|
||||
|
||||
Recently I had to shortlist a pool of providers for a project with specific
|
||||
requirements (privacy jurisdiction, payment methods, traffic limits). I couldn't
|
||||
afford to trust the marketing, so I built a quick verification pass. It caught
|
||||
real problems — providers whose "operating for years" claim was younger than my
|
||||
last haircut, providers whose own terms quietly banned the exact thing I wanted
|
||||
to run, and providers on third-party blacklists.
|
||||
|
||||
Here's the checklist, in the order I run it. None of it needs a paid tool.
|
||||
|
||||
## 1. Check the domain age against the "since" claim
|
||||
|
||||
A provider that says *"trusted since 2012"* should have a domain older than my
|
||||
coffee order. If the domain is four months old, "since 2012" is either a
|
||||
re-branded shell or a lie — either way it tells you something about how the
|
||||
company describes itself.
|
||||
|
||||
The quickest check is a WHOIS/RDAP lookup. RDAP is the modern replacement for
|
||||
WHOIS and returns JSON, which is easier to parse:
|
||||
|
||||
```bash
|
||||
curl -s "https://rdap.org/domain/example.tld" | python -m json.tool
|
||||
```
|
||||
|
||||
Look at the `events` array for the `registration` event — that's the original
|
||||
creation date, not the last renewal. (Renewal dates and registrar changes will
|
||||
hide further down the timeline; the *first* registration is the one you want.)
|
||||
|
||||
The false-negative rule cuts both ways: a brand-new domain *can* be a legitimate
|
||||
new company. But an old "since" claim on a young domain is always a flag — it
|
||||
means the historical claim isn't independently verifiable, and I treat anything
|
||||
else on that page with the same skepticism.
|
||||
|
||||
## 2. Read the AUP (Acceptable Use Policy) — not the features page
|
||||
|
||||
This is the highest-signal step and the one most people skip. The features page
|
||||
tells you what they *allow you to pay for*. The AUP tells you what they'll
|
||||
suspend you for. Those are different lists.
|
||||
|
||||
The disqualifiers are usually in the "Prohibited activities" section, and the
|
||||
phrasing is what matters:
|
||||
|
||||
- **"TOR nodes", "Tor relays", or "exit nodes"** — if you plan to run anonymity
|
||||
tooling, this is a hard no, and it can hide in a list that *looks* like it's
|
||||
only about abuse.
|
||||
- **"reverse proxies", "anonymizing services", "tunnels"** — this bans far more
|
||||
than you'd think. A lot of legitimate architecture (a caching proxy, a
|
||||
GitOps webhook relay) technically trips this wording.
|
||||
- **Jurisdiction and identity clauses** — "must provide accurate identity",
|
||||
"complies with local law enforcement", or the AUP being bound to a specific
|
||||
country's law. If your whole reason for choosing the provider is jurisdictional
|
||||
distance, this line voids it.
|
||||
|
||||
The One Weird Trick: paste the AUP URL and search for the word you care about.
|
||||
I spent ten minutes reading a whole AUP once before realizing a single
|
||||
`Ctrl-F` for "tor" would have answered my question in five seconds.
|
||||
|
||||
## 3. Cross-check reputation — but on third-party sources, not their page
|
||||
|
||||
Testimonials on the provider's own site are decoration. You want places where
|
||||
the provider can't delete the bad reviews:
|
||||
|
||||
- **Trustpilot** — but read the *trend*, not the average. A 3.5 with a long tail
|
||||
is fine; a 3.5 where the last six months are all 1-star "they suspended my
|
||||
server" reviews is a real signal.
|
||||
- **Reddit, especially r/hosting, r/webhosting, r/sysadmin** — search the brand
|
||||
name. The community has a long memory for exit-scams and mass-suspension
|
||||
events, and it's usually blunt about which providers are "a well-known
|
||||
scammer."
|
||||
- **WHTop / HostAdvice** — the ratings are noisy, but a 1.8/10 with a pattern of
|
||||
the same complaint repeated is different from a 1.8 with a handful of one-off
|
||||
gripes.
|
||||
|
||||
The single most useful signal I've found: **suspension reports.** A provider that
|
||||
responds to abuse complaints by suspending first and charging a "reinstatement
|
||||
fee" second will say so in black and white in someone's review. That's a
|
||||
business-model red flag, not a support incident.
|
||||
|
||||
## 4. Verify the "privacy" claim if it matters to you
|
||||
|
||||
"Privacy-friendly" and "offshore" are marketing words until they're reflected in
|
||||
the actual documents:
|
||||
|
||||
- **The privacy policy's retention section.** A provider that says "we retain
|
||||
records as long as necessary for legal / tax / accounting" is telling you they
|
||||
keep logs. Full stop.
|
||||
- **The payment rail.** Who actually processes crypto, and is it a third-party
|
||||
gateway that itself requires KYC? A self-hosted gateway with no external
|
||||
identity step is a very different privacy posture than "bitcoin via a
|
||||
KYC-reseller."
|
||||
- **The incorporated entity.** "Offshore hosting since 2000" is worth almost
|
||||
nothing if the company is registered in, and bound by the law of, a country in
|
||||
your threat model.
|
||||
|
||||
## 5. Check the traffic terms — "unlimited" rarely means unlimited
|
||||
|
||||
The word "unmetered" has a specific meaning that "unlimited" doesn't. Formally
|
||||
unmetered = you pay a flat rate regardless of transfer, but the *port speed*
|
||||
caps the ceiling. "Unlimited" on a plan with a metered 1 TB cap is just
|
||||
marketing.
|
||||
|
||||
If you're paying for traffic (as opposed to a flat rate), model the real number
|
||||
before you sign up: a relay that both receives *and* forwards a file pays for it
|
||||
twice. A plan that looked cheap on "1 TB included" can be the expensive option
|
||||
once you double the actual transit.
|
||||
|
||||
## The shape of the whole pass
|
||||
|
||||
Run these in order — domain age, AUP, reputation, privacy docs, traffic terms —
|
||||
and score each provider on the same simple numbers. The goal isn't perfection;
|
||||
it's to make the *claims you're actually relying on* explicit and checkable, so
|
||||
you're not discovering the truth from a suspension email at 2 AM.
|
||||
|
||||
It's the same instinct as writing a unit test: state the assumption, then try to
|
||||
break it. Most of the providers I eliminated weren't lying in a way a human
|
||||
could spot from the homepage — they were lying in a way a *ten-minute checklist*
|
||||
caught.
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
title: "Migrating a CodeIgniter 4 App From IIS to OpenLiteSpeed"
|
||||
description: "I moved a 17,000-line CodeIgniter 4 app from Windows IIS to OpenLiteSpeed on CyberPanel. Two fatal errors appeared that IIS had been hiding behind SSO."
|
||||
pubDate: 2026-09-20
|
||||
category: engineering
|
||||
tags: [codeigniter, php, openspeedway, cyberpanel, iis, migration, litespeed, linux]
|
||||
ogImage: /og/migrating-codeigniter-iis-to-openlitespeed.png
|
||||
banner: /banners/migrating-codeigniter-iis-to-openlitespeed.png
|
||||
draft: true
|
||||
---
|
||||
|
||||
I maintain a numerology report generator: a CodeIgniter 4 application that takes a birth date and a name, runs them through a numerology engine, and renders a 19-page A4 report. It had run on Windows Server with IIS for years. In September 2026 I moved it to OpenLiteSpeed on CyberPanel, on Linux, on a different domain.
|
||||
|
||||
The migration itself was unremarkable — copy the files, point the docroot at `public/`, install dependencies. What made it worth writing about is that **the first unauthenticated request to the new host fatally crashed on two routes that had been working fine on IIS for years.**
|
||||
|
||||
Both bugs were real. Both were present on IIS the whole time. Neither was visible, because IIS had an authentik SSO gate in front of the exact routes that were broken.
|
||||
|
||||
## Why this matters
|
||||
|
||||
If you run a self-hosted app behind an authentication gateway, your auth layer is doing more than protecting data — it is also **hiding your bugs**. Anything that only breaks for unauthenticated users never runs unauthenticated, so it never fails. The failure surfaces at the worst possible moment: when you migrate, remove the gate, or expose the route to the public internet.
|
||||
|
||||
In my case the app had been reporting "healthy" for as long as I'd owned it. The moment I pointed a new domain at it, two routes returned HTTP 500. Nothing had changed in the code. The only thing that changed was that the gate was gone.
|
||||
|
||||
The fix for both took about twenty minutes. Finding them took two hours, because the actual error messages were hidden behind CodeIgniter's CLI error renderer.
|
||||
|
||||
## The setup
|
||||
|
||||
The app is a fairly ordinary CI4 project with an unusual architecture detail: the report engine is not a library, it is a **set of HTTP endpoints on the same host**. The controllers generate a request, `curl` it back to `/api/single` on their own domain, and the engine returns JSON with the computed numbers. The controller then renders that into the printable report view.
|
||||
|
||||
That self-call design is what made `CONST_IIS_INTERNAL_BASE` necessary on IIS. Let me come back to it — it turns out to be the interesting part.
|
||||
|
||||
| | Before | After |
|
||||
|---|---|---|
|
||||
| OS | Windows Server | Ubuntu (CyberPanel) |
|
||||
| Web server | IIS | OpenLiteSpeed 1.9.0 |
|
||||
| PHP | 8.4 (Windows build) | 8.4.25 (lsphp84) |
|
||||
| Docroot | `C:\inetpub\calc.hoelee.com` | `/home/<domain>/public_html/public` |
|
||||
| Auth gate | authentik SSO on `/lifecode`, `/api/*` | none |
|
||||
|
||||
## Step 1: Point the docroot at `public/`, not the project root
|
||||
|
||||
CI4 ships with a two-folder layout. `app/`, `vendor/`, `writable/` and `.env` live in the project root. Only `public/` is meant to be web-accessible.
|
||||
|
||||
CyberPanel creates the document root as `public_html`. My first instinct was to extract the whole project into `public_html` and leave the docroot alone.
|
||||
|
||||
**That would have exposed `app/`, `vendor/`, the `.env` file, and `writable/` session data over HTTP.** The `.env` in this project contains a webhook credential. Anyone who guessed `/../.env` — or just fetched `/.env`, since the file sits directly under a served directory — gets it.
|
||||
|
||||
The correct layout is to extract into `public_html` and then repoint the vhost docroot one level deeper, at `public_html/public`:
|
||||
|
||||
```bash
|
||||
# extract the project so that app/, vendor/ and .env sit UNDER public_html
|
||||
cd /home/<domain>/public_html
|
||||
tar -xzf /tmp/deploy.tar.gz
|
||||
|
||||
# the docroot must be public_html/public, NOT public_html
|
||||
sudo sed -i 's#/home/<domain>/public_html\$#/home/<domain>/public_html/public#' \
|
||||
/usr/local/lsws/conf/vhosts/<domain>/vhost.conf
|
||||
|
||||
sudo /usr/local/lsws/bin/lshttpd -t
|
||||
sudo systemctl restart lsws
|
||||
```
|
||||
|
||||
This is the single most important step in the migration, and it is the one most guides skip. If you do nothing else, do this.
|
||||
|
||||
## Step 2: The two fatals IIS was hiding
|
||||
|
||||
### Fatal 1 — `env()` called too early
|
||||
|
||||
The first 500 had no body at all. That is unusual — CI4 normally renders something. An empty 500 usually means PHP died before the framework's error handler was installed.
|
||||
|
||||
CI4's bootstrap loads `app/Config/Constants.php` very early in `Boot::bootWeb()`, via `Boot::loadConstants()`. That happens **before** the `Common.php` helper file is loaded, which is where `env()` is defined.
|
||||
|
||||
So this line:
|
||||
|
||||
```php
|
||||
// app/Config/Constants.php — BROKEN
|
||||
define('CONST_fullBase', env('hoelee.fullBase'));
|
||||
```
|
||||
|
||||
fails with:
|
||||
|
||||
```
|
||||
Fatal error: Uncaught Error: Call to undefined function env()
|
||||
in app/Config/Constants.php:96
|
||||
```
|
||||
|
||||
The rule is absolute: **`Constants.php` may only contain plain constants.** No `env()`, no `getenv()`, no config helper. If you need environment-dependent values there, define them further along the bootstrap — or make the constant a fallback and read the real value later.
|
||||
|
||||
That is exactly what I did. `CONST_fullBase` became a plain string, and the helper that consumes it checks the framework's own `app.baseURL` (which *is* `.env`-driven) first:
|
||||
|
||||
```php
|
||||
// app/Helpers/hoelee_helper.php
|
||||
function getFullBase(bool $selfCall = false): string
|
||||
{
|
||||
// the .env-driven baseURL wins; CONST_fullBase is only a fallback
|
||||
if ($selfCall && defined('CONST_IIS_INTERNAL_BASE') && CONST_IIS_INTERNAL_BASE) {
|
||||
return rtrim(CONST_IIS_INTERNAL_BASE, '/');
|
||||
}
|
||||
$appBase = config('App')->baseURL;
|
||||
if ($appBase) return rtrim($appBase, '/');
|
||||
if (defined('CONST_fullBase') && CONST_fullBase) return rtrim(CONST_fullBase, '/');
|
||||
return '';
|
||||
}
|
||||
```
|
||||
|
||||
**This one was my own doing.** I had introduced it during a secrets-cleanup refactor in the same week — I moved a hardcoded URL into `.env` and called `env()` in `Constants.php` to read it. It worked on my machine because the local `.env` was being read through a different path. It never worked on a clean boot. I caught it within an hour *only because* I deployed and tested; a code review would plausibly have waved it through.
|
||||
|
||||
### Fatal 2 — `parent::__construct()` in a CI4 controller
|
||||
|
||||
The second failure was on `/lifecode`, and this one is a genuine pre-existing bug in the application — not something I introduced.
|
||||
|
||||
The error, once I got past the CLI renderer, was:
|
||||
|
||||
```
|
||||
Error: Cannot call constructor
|
||||
```
|
||||
|
||||
CI4's base `CodeIgniter\Controller` class **has no constructor**. It implements `initController()`, which the framework calls with the request, response, and logger objects. The standard pattern is:
|
||||
|
||||
```php
|
||||
// correct CI4 pattern
|
||||
public function initController(
|
||||
RequestInterface $request,
|
||||
ResponseInterface $response,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
parent::initController($request, $response, $logger);
|
||||
// your setup here
|
||||
}
|
||||
```
|
||||
|
||||
But `Lifecode.php` and `ApiEn.php` declared a classic constructor and called `parent::__construct()`:
|
||||
|
||||
```php
|
||||
// BROKEN — CodeIgniter\Controller has no __construct()
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`parent::__construct()` on a parent class that does not define `__construct()` is a fatal in PHP 8. Converting both controllers to `initController()` fixed it — and required adding the three `use` statements for the interface types in the new signature.
|
||||
|
||||
I checked whether the base class *really* had no constructor rather than trusting the error message:
|
||||
|
||||
```bash
|
||||
grep -n 'function __construct\|function initController' \
|
||||
vendor/codeigniter4/framework/system/Controller.php
|
||||
```
|
||||
|
||||
Only `initController()` came back. Worth doing — "Cannot call constructor" also fires when a parent *has* a constructor that errors internally, and you want to know which case you are in before you rewrite the signature.
|
||||
|
||||
## Why neither bug showed up on IIS
|
||||
|
||||
This is the part that changed how I think about the deployment.
|
||||
|
||||
On the IIS host, `/lifecode` and `/api/*` sat behind authentik SSO. An unauthenticated request to either returned **HTTP 302 to `auth.hoelee.com`** — it never reached the controller at all. The broken constructor was never executed. The route had presumably been broken since it was written, and it had never once been asked to serve a request.
|
||||
|
||||
I confirmed this by comparing the two hosts directly:
|
||||
|
||||
```bash
|
||||
curl -sI https://calc.hoelee.com/lifecode | head -1
|
||||
# HTTP/2 302 <- authentik redirect; controller never runs
|
||||
|
||||
curl -sI http://<new-host>/lifecode | head -1
|
||||
# HTTP/1.1 500 <- no gate; controller runs and fatals
|
||||
```
|
||||
|
||||
The 302 is why the bug survived. The app looked healthy because the unhealthy parts were unreachable.
|
||||
|
||||
**The lesson, stated plainly: an auth gate in front of a route means that route has no working test coverage for its own code.** If you migrate or remove the gate, budget time to hit every previously-gated route unauthenticated before you call the migration done. On a small app that is a ten-line `curl` loop. Here it would have found both bugs in seconds instead of two hours:
|
||||
|
||||
```bash
|
||||
for p in / /read/single /read/partner /lifecode /api/date /api/single; do
|
||||
printf '%-16s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://<host>$p)"
|
||||
done
|
||||
```
|
||||
|
||||
## Step 3: The self-call that stops being a loopback
|
||||
|
||||
Now the interesting architectural consequence.
|
||||
|
||||
The report engine is reached by the controller `curl`-ing its own host. On IIS, `CONST_IIS_INTERNAL_BASE` pointed that call at `http://localhost:7296` — the loopback interface — so the request never left the machine and never touched TLS or DNS.
|
||||
|
||||
On LiteSpeed that constant is deliberately left **undefined**, so `getFullBase()` falls through to `app.baseURL`. Which means every report generation now makes a **real outbound HTTPS request to the app's own public URL** and comes back in through Cloudflare.
|
||||
|
||||
Which raises the obvious question: does that work at all?
|
||||
|
||||
The answer is yes, but it is worth testing explicitly, because the failure mode is confusing. Here is the exact test — run it **on the server**, not from your laptop:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'HTTPS self-call: %{http_code}\n' \
|
||||
-X POST 'https://<domain>/api/single' -d 'nameCn=test&dob=1990-01-01'
|
||||
```
|
||||
|
||||
Two things to watch for:
|
||||
|
||||
- **Run it from the server.** From my Windows machine the same URL returned 404 and 500 — an artefact of DNS resolution and Cloudflare bot protection on my caller, not a server problem. Testing from the wrong host produces confident, wrong conclusions.
|
||||
- **Watch the JSON, not just the status code.** A 200 with a truncated body is worse than a clean failure. I checked the response was parseable before trusting it.
|
||||
|
||||
There is a real trade-off hiding here, and I have not fully resolved it: a public-URL self-call means report generation depends on Cloudflare being up, costs a TLS handshake per report, and occupies two PHP workers for the duration. On LiteSpeed with a small worker pool, a burst of concurrent reports could deadlock — each request waiting on another request that has no free worker. For the current traffic level it is fine. Before this scales, the self-call should move to an internal path that bypasses the CDN.
|
||||
|
||||
## Step 4: Verify the actual output, not the status code
|
||||
|
||||
A 200 on `/` proves the landing page renders. It does not prove the report engine works, and the engine is the entire product. So the last step was generating a real report end to end:
|
||||
|
||||
```bash
|
||||
curl -s -X POST 'https://<domain>/read/single' \
|
||||
-d 'nameCn=test&dob=1990-01-01&gender=m' \
|
||||
-o report.html -w 'HTTP:%{http_code} bytes:%{size_download}\n'
|
||||
```
|
||||
|
||||
The result: **HTTP 200, 99,189 bytes, 19 A4 pages.** I ran it again with Chinese-character input to exercise the UTF-8 path through the engine — HTTP 200, 99,205 bytes, 19 pages, name rendering correctly, zero PHP warnings in the output.
|
||||
|
||||
Byte-comparable page counts between the old and new host is the check that matters. It proves the fonts, the DPI, and the pagination logic all survived the move.
|
||||
|
||||
A side note on the UTF-8 test: my first attempt at it returned a 500 and I nearly went bug-hunting. The cause was my own shell — the Chinese characters were being mangled by the terminal encoding on the way into `curl`, so the app received invalid bytes. Running the same request from a script on the server, where the encoding is under my control, returned 200. **Before you debug an encoding failure, confirm the bytes actually arriving at the server are the bytes you meant to send.**
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
**Test unauthenticated routes before migrating, not after.** The whole two-hour debugging session was avoidable with a `curl` loop over the route list. I now treat "list every route, hit it unauthenticated, record the status" as step zero of any migration.
|
||||
|
||||
**Do not call `env()` in `Constants.php`.** I have left a comment in the file itself saying so, because the next person — probably me in six months — will be tempted.
|
||||
|
||||
**Check the framework version's project-space config before the move.** The same week, I upgraded CI4 from 4.6.3 to 4.7.4, and it fataled twice on properties the upgrade guide did not mention: `Config\App::$permittedURIChars`, required by the 4.7 Router, and `Config\Format::$jsonEncodeDepth`, required by the JSONFormatter — the second of which broke the engine's JSON self-call specifically. Composer updates `vendor/` but never merges `app/Config/*.php`, because those are project-space files. I ended up merging new properties into 14 config files. That is a separate post, but the migration lesson is the same shape: **the framework tells you what changed in `vendor/`; nothing tells you what changed in `app/`.**
|
||||
|
||||
**Confirm the docroot before anything else.** If I had extracted into `public_html` and stopped, the app would have worked — and quietly served `.env` over HTTP. A migration that works is not the same as a migration that is safe.
|
||||
|
||||
## The result
|
||||
|
||||
One evening. Six routes verified 200, byte-comparable report output on both Chinese and ASCII input, zero PHP warnings, and two long-standing latent bugs removed from the codebase that IIS had been concealing behind an auth gate.
|
||||
|
||||
The app is now running on OpenLiteSpeed with a properly separated docroot, on a host I can automate, with the credential in `.env` rather than a constant.
|
||||
|
||||
---
|
||||
|
||||
**Want this done for your own application?** I migrate PHP applications between IIS, Apache, nginx and LiteSpeed — including the awkward parts: self-calling architectures, SSO gates that hide bugs, and framework upgrades that touch project-space config.
|
||||
|
||||
[WhatsApp +60 12-797 2969](https://wa.me/60127972969) · [[email protected]](mailto:[email protected]?subject=CodeIgniter%20migration) · [hoelee.com](https://hoelee.com)
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
title: "Upgrading n8n v1 to v2: Seven Deprecations in One Log File"
|
||||
description: "My self-hosted n8n 1.x to 2.40.1 upgrade surfaced seven silent breakages at once — a telemetry schema rejection, deprecated webhook vars, and a DB override that quietly disabled the AI sandbox."
|
||||
pubDate: 2026-09-18
|
||||
category: devops
|
||||
tags: [n8n, docker, upgrade, self-hosting, debugging, automation]
|
||||
ogImage: /og/n8n-v1-to-v2-upgrade-gotchas.png
|
||||
banner: /banners/n8n-v1-to-v2-upgrade-gotchas.png
|
||||
---
|
||||
|
||||
I run n8n as the automation backbone for my self-hosted stack — it handles
|
||||
file-delivery permissions, database backups, and a text-to-speech API that a
|
||||
reading app depends on. It had been sitting on the `1.123.x` line for the
|
||||
better part of a year, quietly doing its job.
|
||||
|
||||
Then I pulled `n8nio/n8n:2.40.1` and restarted the container. The upgrade
|
||||
itself took about ninety seconds. Understanding what it *broke* took the rest
|
||||
of the evening — and almost all of it was already written down in a single log
|
||||
file that n8n prints on boot. I just hadn't read it carefully enough the first
|
||||
time.
|
||||
|
||||
This is that log file, decoded, so you can plan your own v1 → v2 jump instead
|
||||
of discovering these at 11 PM.
|
||||
|
||||
## Why it matters
|
||||
|
||||
Major-version upgrades of an automation platform are different from upgrading
|
||||
a leaf service. n8n is *the thing that runs everything else*: if it comes up
|
||||
broken, your backups, your access-control syncs, and your internal APIs all
|
||||
stop with it. Worse, most of what breaks in v2 doesn't throw an error — it
|
||||
logs a deprecation notice once and then quietly does something different.
|
||||
|
||||
The seven items below are the ones that actually applied to a real, messy,
|
||||
production-shaped install. Three of them changed behavior in my stack. One of
|
||||
them silently turned a feature *off*.
|
||||
|
||||
## Start here: n8n tells you what's wrong at boot
|
||||
|
||||
Before touching a single workflow, read the container log from the top. On a
|
||||
fresh v2 boot, n8n prints an explicit deprecation block:
|
||||
|
||||
```text
|
||||
There are deprecations related to your n8n setup. Please take the recommended
|
||||
actions to update your configuration:
|
||||
- WEBHOOK_URL -> Use N8N_WEBHOOK_URL instead, which sets the base URL for
|
||||
both test and production webhooks.
|
||||
- N8N_UNVERIFIED_PACKAGES_ENABLED -> The default for this variable will
|
||||
change to `false` in a future version.
|
||||
- N8N_RUNNERS_MODE -> Internal task runner mode is deprecated and will be
|
||||
removed in a future version.
|
||||
- N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
|
||||
from 300 (5 minutes) to 60 (1 minute) in a future version.
|
||||
- N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> The default will be
|
||||
reduced from 2 GiB to 256 MiB in a future version.
|
||||
- N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> The default will be reduced from
|
||||
5000 to 1000 in a future version.
|
||||
```
|
||||
|
||||
That block is your migration checklist. Six of my seven gotchas are in it.
|
||||
|
||||
## 1. Your env values can now fail *schema validation*
|
||||
|
||||
Here is the one that confused me most, because it looked like a nonsense error:
|
||||
|
||||
```text
|
||||
Telemetry event "Instance started" failed schema validation:
|
||||
execution_variables.executions_data_save_on_error: Invalid option:
|
||||
expected one of "all"|"none"
|
||||
```
|
||||
|
||||
I had `EXECUTIONS_DATA_SAVE_ON_ERROR=error` set — a value that was perfectly
|
||||
legal in v1, and that I'd chosen deliberately, because saving *only* failed
|
||||
executions is the sane default for a busy instance. In v2 that value is no
|
||||
longer in the allowed set, which is now `all` or `none`.
|
||||
|
||||
The failure mode is the interesting part. It didn't crash. It didn't even warn
|
||||
in a way that reads like an error at a glance — it emitted a *telemetry schema
|
||||
validation* message, which sounds like an n8n-internal problem, not a
|
||||
configuration problem of mine. The setting was effectively ignored.
|
||||
|
||||
The fix is to move that intent somewhere coherent: pick a legal value and
|
||||
control volume with pruning instead.
|
||||
|
||||
```env
|
||||
EXECUTIONS_DATA_SAVE_ON_ERROR=all
|
||||
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
|
||||
EXECUTIONS_DATA_PRUNE=true
|
||||
EXECUTIONS_DATA_MAX_AGE=336
|
||||
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
|
||||
```
|
||||
|
||||
**Lesson:** in v2, treat your environment variables as a typed interface with
|
||||
a schema. An invalid value may be dropped silently rather than rejected loudly.
|
||||
|
||||
## 2. `WEBHOOK_URL` is deprecated for `N8N_WEBHOOK_URL`
|
||||
|
||||
If you publish webhooks behind a reverse proxy — which you almost certainly do,
|
||||
because that's how they become reachable — the base URL variable is load-bearing.
|
||||
It's what makes n8n report the *public* webhook path instead of
|
||||
`http://localhost:5678/...`.
|
||||
|
||||
The old name still works today, so this one won't bite immediately. But note
|
||||
the wording: the new variable sets the base URL for **both test and production
|
||||
webhooks**. In my setup those had drifted apart in behavior, which is exactly
|
||||
the class of bug this consolidation is meant to eliminate.
|
||||
|
||||
```env
|
||||
# before (still functional, deprecated)
|
||||
WEBHOOK_URL=https://auto.example.com/
|
||||
|
||||
# after
|
||||
N8N_WEBHOOK_URL=https://auto.example.com/
|
||||
```
|
||||
|
||||
## 3. Internal task runner mode is going away — and mine was already broken
|
||||
|
||||
This one was sitting in my logs the whole time, several lines above the
|
||||
deprecation block, and I'd been reading past it for months:
|
||||
|
||||
```text
|
||||
Failed to start Python task runner in internal mode. because Python 3 is
|
||||
missing from this system. Launching a Python runner in internal mode is
|
||||
intended only for debugging and is not recommended for production.
|
||||
```
|
||||
|
||||
If any workflow of yours uses a **Python** Code node, it has not been running
|
||||
in internal mode at all — there's no Python in the stock image. JavaScript
|
||||
Code nodes are fine (a JS runner registers normally), which is why this can go
|
||||
unnoticed indefinitely: everything *looks* healthy.
|
||||
|
||||
v2 makes the direction of travel explicit: switch to `external` mode and share
|
||||
an auth token with a separate launcher process.
|
||||
|
||||
```env
|
||||
N8N_RUNNERS_MODE=external
|
||||
N8N_RUNNERS_AUTH_TOKEN=<a long random string>
|
||||
```
|
||||
|
||||
**Lesson:** "internal mode is deprecated" is the headline, but the real
|
||||
finding is that a runner type can be *silently non-functional* for months.
|
||||
Check `docker logs` for the runner registration line, not just for up/down.
|
||||
|
||||
## 4. Task timeout drops from 300s to 60s
|
||||
|
||||
This is the one I'd flag hardest for anyone with slow workflows:
|
||||
|
||||
```text
|
||||
N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
|
||||
from 300 (5 minutes) to 60 (1 minute) in a future version.
|
||||
```
|
||||
|
||||
Be honest about your own workloads. Do you have a Code node that loops over
|
||||
thousands of records, or an HTTP call to a slow upstream? Mine do — a nightly
|
||||
reconciliation walks every customer and calls an external API per record. On a
|
||||
future upgrade, that stops at sixty seconds with no config change on my side.
|
||||
|
||||
Set it explicitly now, while you're already in the file:
|
||||
|
||||
```env
|
||||
N8N_RUNNERS_TASK_TIMEOUT=300
|
||||
```
|
||||
|
||||
The general principle for every deprecation of the form "the default will
|
||||
change": **if you rely on the current default, pin it explicitly.** Otherwise
|
||||
the upgrade is a silent behavior change, and you'll debug it as a bug rather
|
||||
than recognise it as a stale default.
|
||||
|
||||
## 5 & 6. Two compression-node limits shrink (2 GiB → 256 MiB, 5000 → 1000 entries)
|
||||
|
||||
These two travel together and matter only if you use compression/decompression
|
||||
nodes on large payloads — which is easy to end up doing when you're shuttling
|
||||
database dumps or archives through a workflow.
|
||||
|
||||
```text
|
||||
N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> reduced from 2 GiB to
|
||||
256 MiB in a future version.
|
||||
N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> reduced from 5000 to 1000 in a
|
||||
future version.
|
||||
```
|
||||
|
||||
An eighth of the memory ceiling and a fifth of the entry limit. Nothing errors;
|
||||
the node just refuses at a threshold you didn't set. Pin both if you're near
|
||||
either.
|
||||
|
||||
## 7. The storage path renames in v3 — and you have a volume mounted at the old one
|
||||
|
||||
Not a v2 breakage, but v2 warns about it, and it's the one with real data-planning
|
||||
implications:
|
||||
|
||||
```text
|
||||
Deprecation warning: The storage directory "/home/node/.n8n/binaryData" will
|
||||
be renamed to "/home/node/.n8n/storage" in n8n v3. To migrate now, set
|
||||
N8N_MIGRATE_FS_STORAGE_PATH=true. If you have a volume mounted at the old
|
||||
path, update your mount configuration after migration.
|
||||
```
|
||||
|
||||
Read that last sentence again: *if you have a volume mounted at the old path,
|
||||
update your mount configuration after migration.* If you set the migration flag
|
||||
and keep your old bind mount, you now have two directories and your binary data
|
||||
lives in whichever one the container is actually pointed at. Do the rename and
|
||||
the mount change in the same maintenance window — not one now, one "later".
|
||||
|
||||
## The one that wasn't in the log: my AI sandbox disabled itself
|
||||
|
||||
Here's the finding that had nothing to do with a deprecation notice, and that
|
||||
I'd never have caught without reading the full boot sequence:
|
||||
|
||||
```text
|
||||
Sandbox: enabled=false provider=n8n-sandbox (DB override; env was enabled=true
|
||||
provider=n8n-sandbox)
|
||||
```
|
||||
|
||||
My environment said enabled. The database said otherwise. **The database won.**
|
||||
|
||||
The env var was `N8N_INSTANCE_AI_SANDBOX_ENABLED=true`, and it was still set
|
||||
correctly on the container. But a value persisted in n8n's own configuration
|
||||
store overrode it at startup — and the only place that conflict is reported is
|
||||
inside a parenthetical in a log line.
|
||||
|
||||
This is a genuinely valuable debugging lesson beyond n8n: when a feature is off
|
||||
despite the env var being obviously right, suspect a **persisted settings layer
|
||||
that outranks your environment**. The container config is not always the last
|
||||
word. Grep for the feature name in the logs, and don't stop at the env value.
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
1. **Read the boot log before declaring the upgrade done.** Every deprecation
|
||||
that mattered to me was printed on startup, in one block, on the first run.
|
||||
My v1 habit was to check "is it up, do the workflows run" — which is exactly
|
||||
the check that misses all seven of these.
|
||||
2. **Treat "the default will change" as a to-do, not a warning.** Five of the
|
||||
seven items are future-default changes. Pinning them now costs one edit and
|
||||
converts a mystery outage later into a config diff.
|
||||
3. **Diff the container config against n8n's own stored config.** The sandbox
|
||||
override taught me that env is one of two inputs, not the source of truth.
|
||||
When behavior and configuration disagree, believe the behavior and go find
|
||||
the higher-priority layer.
|
||||
4. **Pin the image tag and keep the previous one.** I jumped v1 → `2.40.1`
|
||||
directly. Having the old image on disk is what makes a rollback a
|
||||
`docker run` instead of a rebuild.
|
||||
|
||||
## The result
|
||||
|
||||
n8n is on 2.40.1 with the full deprecation block resolved: the schema-invalid
|
||||
value corrected, `N8N_WEBHOOK_URL` in place, the JS runner registering cleanly,
|
||||
and the task timeout and compression limits pinned so the next upgrade is a
|
||||
no-op rather than a surprise.
|
||||
|
||||
Everything downstream kept working — the file-permission syncs, the nightly
|
||||
backups, the text-to-speech endpoint. That's the outcome worth aiming for with
|
||||
an upgrade like this: not "it came back up", but "it came back up *and* the
|
||||
next three upgrades are already paid for".
|
||||
|
||||
The uncomfortable part is how much of it I could have known in advance. n8n
|
||||
handed me the entire list, unprompted, at startup. The upgrade was never the
|
||||
hard part — reading the output was.
|
||||
|
||||
---
|
||||
|
||||
## Running automation you'd rather not babysit?
|
||||
|
||||
I build and maintain self-hosted automation — n8n workflows, Docker stacks,
|
||||
and the glue between apps that were never designed to talk to each other. If
|
||||
you're facing a major version upgrade, or you have automation that works until
|
||||
it doesn't, I plan the migration, do it in a maintenance window, and document
|
||||
every config decision so the next upgrade is boring.
|
||||
|
||||
Reach me at [[email protected]](mailto:[email protected]?subject=n8n%20upgrade) or
|
||||
WhatsApp [+60 12-797 2969](https://wa.me/60127972969), or see what I do at
|
||||
[hoelee.com](https://hoelee.com).
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: "Passbolt UI Kept Hanging — Three Failure Modes From One Missing Config File"
|
||||
description: "My self-hosted Passbolt returned 504s for weeks: a null GPG fingerprint stalled email delivery, a container recreate wiped the fix, and ssl.force behind a proxy caused a redirect loop."
|
||||
pubDate: 2026-09-13
|
||||
category: devops
|
||||
tags: [passbolt, docker, gpg, smtp, reverse-proxy, portainer]
|
||||
ogImage: /og/passbolt-hang-three-failure-modes.png
|
||||
banner: /banners/passbolt-hang-three-failure-modes.png
|
||||
---
|
||||
|
||||
I run Passbolt — an open-source password manager for teams — on a Synology
|
||||
NAS in Docker, behind an nginx reverse proxy. For a stretch of weeks it
|
||||
developed a nasty habit: the web UI would hang, return `504 Gateway Timeout`,
|
||||
and refuse to hand over a password right when I needed it. This is the story
|
||||
of chasing that hang to its root — and the twist where my own "fix" caused a
|
||||
brand-new failure mode.
|
||||
|
||||
## Why it matters
|
||||
|
||||
A password manager is the one app you cannot afford to be flaky. When I'm
|
||||
mid-conversation with a client and need a server credential, a loading spinner
|
||||
is not an option. The symptoms looked random — sometimes up, sometimes
|
||||
504 — but the cause turned out to be a single missing file, plus two mistakes
|
||||
stacked on top of it. If you self-host Passbolt in Docker, one of these three
|
||||
is probably in your future.
|
||||
|
||||
## The symptom: cron hangs, and the UI follows
|
||||
|
||||
Passbolt runs a cron job every minute that processes its email queue — account
|
||||
recovery links, share notifications, test messages. Normally it completes in
|
||||
under a second. Mine was taking 30–60 seconds every single run.
|
||||
|
||||
Why does that matter? Behind the scenes Passbolt serves the web UI through a
|
||||
pool of PHP-FPM workers. When the cron job stalls on email sending, it ties up
|
||||
a worker. Enough stalled crons, and the pool is exhausted — so ordinary page
|
||||
requests queue up, nginx times out, and you get the 504.
|
||||
|
||||
The logs told the story plainly:
|
||||
|
||||
```text
|
||||
not starting: job is still running since ... (1m elapsed)
|
||||
```
|
||||
|
||||
## Root cause 1: a null GPG fingerprint
|
||||
|
||||
Passbolt stores its SMTP settings encrypted with the server's GPG key. To use
|
||||
them, it needs to know which key to decrypt with — a value read from
|
||||
`passbolt.gpg.serverFingerprint`. That value comes from a config file,
|
||||
`/etc/passbolt/passbolt.php`.
|
||||
|
||||
That file didn't exist.
|
||||
|
||||
Without it, the fingerprint resolved to `null`, GPG decryption of the SMTP
|
||||
settings failed, and every cron iteration that tried to send mail hung on the
|
||||
failure. The healthcheck surfaced it as:
|
||||
|
||||
```text
|
||||
SMTP Setting errors: ... setDecryptKeyFromFingerprint():
|
||||
Argument #1 ($fingerprint) must be of type string, null given
|
||||
```
|
||||
|
||||
There was also one genuinely dead email in the queue — an old `SMTP timeout`
|
||||
record that had exhausted its retries months ago and would never send. It got
|
||||
re-scanned every minute, adding to the stall.
|
||||
|
||||
```sql
|
||||
DELETE FROM email_queue WHERE sent = 0 AND send_tries >= 4;
|
||||
```
|
||||
|
||||
## Root cause 2: the fix that didn't survive (the relapse)
|
||||
|
||||
Here's the part that stung. I had fixed this exact bug once before — by
|
||||
creating `/etc/passbolt/passbolt.php` *inside the container*. Two weeks later
|
||||
the hang came back, and the file was gone.
|
||||
|
||||
The reason: I'd recreated the container during maintenance, and a Docker
|
||||
container's writable layer is **ephemeral by design**. Anything written to the
|
||||
filesystem inside the container (rather than into a mounted volume) vanishes on
|
||||
recreate. My fix had the shelf life of the container, not of the deployment.
|
||||
|
||||
The durable version is three pieces that all live *outside* the container:
|
||||
|
||||
1. A config file on the host, bind-mounted into the image at
|
||||
`/etc/passbolt/passbolt.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
return [
|
||||
'App' => [
|
||||
'fullBaseUrl' => env('APP_FULL_BASE_URL', 'https://pass.example.com'),
|
||||
],
|
||||
'passbolt' => [
|
||||
'gpg' => [
|
||||
'serverFingerprint' => 'A3DD9B762D48722C10CF88DDB5372E46A54E1419',
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
2. The bind mount in compose — the part that makes it survive recreate:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /volume1/docker/passbolt/session-config/passbolt.php:/etc/passbolt/passbolt.php:ro
|
||||
```
|
||||
|
||||
3. The correct environment variable name. I'd been using
|
||||
`PASSBOLT_GPG_SERVER_FINGERPRINT`, which Passbolt silently ignores. The
|
||||
real variable is `PASSBOLT_GPG_SERVER_KEY_FINGERPRINT` — and it wants the
|
||||
**full 40-character** fingerprint, not a truncated one:
|
||||
|
||||
```yaml
|
||||
PASSBOLT_GPG_SERVER_KEY_FINGERPRINT: "A3DD9B762D48722C10CF88DDB5372E46A54E1419"
|
||||
```
|
||||
|
||||
## Root cause 3: the redirect loop I caused myself
|
||||
|
||||
With the hang fixed, I upgraded Passbolt from 5.14.3 to 5.15.0. Then, browsing
|
||||
to `https://pass.example.com/`, the browser threw:
|
||||
|
||||
```text
|
||||
ERR_TOO_MANY_REDIRECTS
|
||||
```
|
||||
|
||||
I assumed the upgrade had broken something. It hadn't. The culprit was
|
||||
`ssl.force`, which I'd left in my config file:
|
||||
|
||||
```php
|
||||
'passbolt' => [
|
||||
'ssl' => [
|
||||
'force' => true,
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
Passbolt's SSL-force middleware inspects the request's scheme, and behind a
|
||||
TLS-terminating reverse proxy that scheme is **always `http`** — because the
|
||||
proxy (nginx) terminates the TLS connection, then forwards plain HTTP to the
|
||||
container. So the middleware sees `http`, dutifully redirects to
|
||||
`https://same-url`, nginx forwards it back as `http`, and the cycle repeats
|
||||
forever.
|
||||
|
||||
The fix is to understand who owns SSL. If your reverse proxy already enforces
|
||||
HTTPS at the edge, the app must **not** try to force it again. Removing
|
||||
`ssl.force` from the config (leaving TLS to nginx) broke the loop immediately.
|
||||
|
||||
The general rule, which applies well beyond Passbolt to any app behind a
|
||||
reverse proxy: **either** the proxy terminates TLS, **or** the app does — never
|
||||
both, and never configure `ssl.force` in the app while the proxy already
|
||||
handles it.
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
1. **Config lives in volumes, not in containers.** The moment I wrote a file
|
||||
into `docker exec`, I'd committed to redoing it on the next recreate. The
|
||||
reflex should be: does this need to survive a `docker compose up`? Then it
|
||||
goes in a bind mount, not the writable layer.
|
||||
2. **Read the actual variable names.** The fingerprint env var cost me a clean
|
||||
diagnosis because I trusted a wrong name that Passbolt ignored without
|
||||
complaint. When a config value "doesn't work," check the upstream reference
|
||||
for the exact key before assuming anything else is wrong.
|
||||
3. **Test the public URL, not just the healthcheck.** The healthcheck passed
|
||||
every time and missed the redirect loop entirely because it never exercised
|
||||
the real public path through the proxy. A two-second `curl -I` against the
|
||||
live URL would have caught it instantly.
|
||||
|
||||
## The result
|
||||
|
||||
The hang is gone: cron completes in under a second (down from 30–60s), the
|
||||
email queue is clear, and the web UI responds in milliseconds instead of
|
||||
504-ing. Passbolt is now running the current 5.15.0, with every GPG and SMTP
|
||||
healthcheck green — and the fix is written in a way that survives the next
|
||||
container recreate.
|
||||
|
||||
The lesson worth carrying: a self-hosted service that "sometimes hangs" is
|
||||
rarely a mystery. It's usually one missing config value, surfaced three
|
||||
different ways. Fix the config, not the symptom.
|
||||
|
||||
---
|
||||
|
||||
## Want stable self-hosted infrastructure for your business?
|
||||
|
||||
If you run services like Passbolt, a password manager, email, or a dashboard
|
||||
and they're flaky — hanging UIs, 504s, mysteries that vanish on restart — I
|
||||
diagnose and fix exactly these problems. I work with Docker, reverse proxies,
|
||||
and self-hosted stacks, and I hand everything back documented so the next
|
||||
person (or future you) isn't left guessing.
|
||||
|
||||
Reach me at [[email protected]](mailto:[email protected]) or WhatsApp
|
||||
[+60 12-797 2969](https://wa.me/60127972969), or see what I do at
|
||||
[hoelee.com](https://hoelee.com).
|
||||