Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Blog Material You

A self-hosted blog built on a Node.js + TypeScript backend with local Markdown storage (Hexo-like, no database at all), a MDUI 2 (Material Design 3) frontend, and full server-side rendering.

Every post, page, talk and friend link is a Markdown file with YAML frontmatter under blog/. Every piece of runtime state — admin credentials, comments, visitor permissions, the TOTP secret, image-host settings — is a JSON file under blog/data/. There is no MariaDB, no nginx, no OpenResty, no Python, and no external CDN: one Node.js process serves the public site, the admin console and both JSON APIs on a single port.

This is a ground-up rewrite of the previous OpenResty/Lua + MariaDB implementation. No database is required any more. Upgrading is supported: see Migrating from the old MariaDB version.


Project Structure

Blog-Material-You/
├── config.yml                # ★ Master site config (tracked) — name, description, avatar,
│                             #   menu, feature switches, list lengths, UI strings.
│                             #   Copied to _config.yml on boot when newer
├── _config.yml               #   Runtime working copy (gitignored): the only file the admin
│                             #   console writes; every write triggers a static rebuild
├── src/                      # Node.js + TypeScript application (run directly, no build step)
│   ├── index.ts              # Entry point: runtime dirs, vendor assets, admin bootstrap, HTTP server
│   ├── app.ts                # Express wiring: security headers, Origin guard, static, routes, 404/500
│   ├── paths.ts              # Every filesystem location + environment variable resolution
│   ├── version.ts            # Application version (reported by /api/health and /api/status)
│   ├── site-config.ts        # Loads _config.yml, merges it over built-in defaults, and the
│   │                         # complete default UI string dictionary + default menu
│   ├── content/              # Markdown content model (the "database")
│   │   ├── markdown-files.ts # mtime/size-cached scanner, frontmatter parser, atomic writer
│   │   ├── posts.ts          # blog/posts/*.md       → Post
│   │   ├── pages.ts          # blog/pages/*.md       → Page
│   │   ├── talks.ts          # blog/talks/*.md       → Talk (short "moments")
│   │   └── friends.ts        # blog/friends/*.md     → Friend
│   ├── store/                # Runtime state, one JSON file per concern
│   │   ├── json-store.ts     # Atomic, mtime-cached JSON file store
│   │   ├── credentials.ts    # blog/data/admin.json    (scrypt + legacy AES records)
│   │   ├── totp-state.ts     # blog/data/totp.json     (2FA secret, pending secret)
│   │   ├── visitors.ts       # blog/data/visitors.json (registered emails + pending queue)
│   │   ├── comments.ts       # blog/data/comments/<key>.json (one file per page URL)
│   │   ├── calendar.ts       # blog/data/calendar.json
│   │   └── imghost-config.ts # blog/data/imghost.json  (SFTP / S3 settings)
│   ├── auth/                 # password, secret, session, TOTP, visitor session
│   ├── middleware/           # admin-auth.ts — API and HTML page guards
│   ├── media/                # imghost.ts (S3/SFTP upload), avatar.ts (sharp → 512×512 WebP)
│   ├── lib/                  # markdown.ts, text.ts, ratelimit.ts, backup.ts
│   ├── render/               # engine.ts (EJS), seo.ts (per-route title/description/canonical)
│   └── routes/               # public-pages.ts, public-api.ts, admin-pages.ts, admin-api.ts
├── views/                    # EJS templates (server-rendered, no client-side templating)
│   ├── layouts/              # public.ejs, admin.ejs
│   ├── pages/                # home, posts, post, list, tags, categories, archives,
│   │                         # talks, friends, page, status, auth, 404
│   ├── admin/                # dashboard, posts, editor, pages, page-editor, talks,
│   │                         # talk-editor, friends, friend-editor, comments, visitors,
│   │                         # security, imghost, backup, login, setup
│   └── game/2048.ejs         # Easter egg
├── blog/                     # Content + static assets + runtime state
│   ├── posts/                # <slug>.md
│   ├── pages/                # <slug>.md  (+ optional legacy <slug>.en.json)
│   ├── talks/                # <unixtime>.md
│   ├── friends/              # <id>-<slug>.md
│   ├── data/                 # Runtime state (gitignored): credentials, comments,
│   │                         # visitors, TOTP, sessions, calendar/imghost settings
│   └── public/               # Static assets served under /css, /js, /icon, /img, …
│       └── vendor/           # GENERATED: mdui, KaTeX, highlight.js copied from node_modules
├── scripts/
│   ├── sync-assets.mjs       # Vendors mdui/KaTeX/highlight.js into blog/public/vendor
│   ├── import-legacy-mariadb.mjs  # One-time migration from the old MariaDB install
│   └── passwd-reset.mjs      # Offline admin-credential recovery (npm run passwd-reset)
├── Dockerfile                # node:24-alpine, non-root, HEALTHCHECK
├── docker-compose.yml        # Single container + volumes
├── .env.example              # Every supported environment variable, documented
├── package.json              # npm start / dev / typecheck / assets / passwd-reset
├── tsconfig.json             # strict, NodeNext, noEmit (Node runs the .ts files directly)
└── README.md / README.zh.md

Where state lives

Path Contents Gitignored
blog/posts/, blog/pages/, blog/talks/, blog/friends/ Your content as Markdown — this is the database yes (only .gitkeep files are tracked)
blog/data/ Runtime state: admin.json (credentials), totp.json, visitors.json, calendar.json, imghost.json, secret.json, visits.json (page-view counters), comments/, sessions/, image-cache/ yes
blog/public/vendor/ mdui / KaTeX / highlight.js, generated from node_modules by scripts/sync-assets.mjs yes
blog/public/avatars/, blog/public/uploads/ Uploaded images yes

blog/public/vendor/ is generated, not source. npm install runs sync-assets.mjs via postinstall, and the server regenerates the bundle on boot if it is missing, so a fresh clone still works offline — the site never loads a public CDN at runtime.


Quick Start

Requirements

  • Node.js ≥ 22.18 (the app runs TypeScript directly through Node's built-in type stripping — there is no build step)
  • No database, no web server, no Python

Local

npm install     # installs dependencies and vendors the front-end libraries
npm start       # blog → http://localhost:30999/ · profile card → http://localhost:31000/

Useful scripts:

Command Purpose
npm start Run the server (node src/index.ts)
npm run dev Run with --watch (restarts on file changes)
npm run build Render every public page into a static site under blog/dist/
npm run serve:api Serve only the APIs / admin console / static assets / image resizing — no SSR pages
npm run typecheck tsc --noEmit
npm run assets Re-vendor mdui / KaTeX / highlight.js into blog/public/vendor

Docker

docker compose up -d --build

docker-compose.yml publishes container port 30999 on host port 8000 by default — change the ports: mapping if you want a different host port.

URLs

Service URL Notes
Blog http://localhost:30999/ (Docker: http://localhost:8000/) Server-rendered
Admin console http://localhost:30999/admin/ Redirects to /admin/dashboard/
Health check http://localhost:30999/api/health {"status":"ok","server":"nodejs","version":"2.0.0"}
Personal card http://localhost:31000/ Own port — / and /profile/ render the same card
First-run setup http://localhost:30999/admin/setup/ Only reachable while no admin exists
Sitemap / RSS /sitemap.xml, /rss.xml Absolute URLs when BMY_SITE_URL is set

Creating the admin account

The first time you open the site there is no admin account. Visit /admin/setup/ and choose a username and password (≥ 6 characters; a few well-known passwords are rejected). The account is written to blog/data/admin.json with a scrypt hash, and /admin/setup/ then redirects to the login page.

For unattended deployments you can create the account from the environment instead: set BMY_ADMIN_USER / BMY_ADMIN_PASS and start the server. This only applies while no admin account exists, so a password you change later in the UI is never silently reverted on restart. Resetting a forgotten password is deliberately not an environment switch — use npm run passwd-reset (see Operations), so nothing in a deployment config can overwrite credentials behind your back.


Content Authoring

All content lives in blog/ as UTF-8 Markdown files with a YAML frontmatter block. The filename (without .md) is the slug used in URLs, in /api/... paths and in the admin console.

Files are scanned lazily and cached by mtime + size. Editing a file with your own editor (or git pull) is picked up on the next request — no restart, no reload endpoint needed. Markdown is rendered on the server, so every page is complete HTML for crawlers and for readers without JavaScript, with:

  • heading anchors (id attributes on every heading, exposed as a table of contents on posts/pages)
  • syntax highlighting via highlight.js (dark and light GitHub themes are vendored)
  • KaTeX math ($…$ inline, $$…$$ display)
  • link auto-detection, tables and blockquotes

Posts — blog/posts/<slug>.md

Field Type Meaning
title string Display title (falls back to the slug)
date YYYY-MM-DD or Unix seconds Sort key and archive year; normalised on read
tags list Used by /tags/, /tags/:name and GET /api/tags
categories list Used by /categories/, /categories/:name and GET /api/categories
cover string Optional card/hero image URL
archived boolean true moves the post to /archives/ and hides it from the home page
title_en string English title (optional)
content_en string English body as YAML (optional)
---
title: 用 Docker 部署一个静态博客
date: 2025-11-23
tags: [Docker, Linux]
categories: [笔记]
cover: /img/cover.jpg
title_en: Deploying a static blog with Docker
---

# 背景

正文内容写在这里。

```bash
docker compose up -d --build

行内公式 $E = mc^2$,块级公式:

$$ \int_0^1 x^2 , dx = \frac{1}{3} $$

小结

更多内容……


Bilingual posts: keep the Chinese body in the Markdown body and put the English body in the `content_en` frontmatter field (a YAML block scalar or a single-quoted string both work). `/api/posts/:slug` then returns `html` and `htmlEn`, and the per-route SEO metadata switches to the English title/description when `title_en` is present.

Lists may be written either as YAML sequences (`[Docker, Linux]`, `- Docker`) or as a comma-separated string — the admin editor sends the latter. If a file's YAML is malformed the app falls back to a tolerant line-based parser (the same behaviour as the previous Lua implementation) and logs a warning instead of dropping the file.

### Pages — `blog/pages/<slug>.md`

Standalone pages. `about.md` is served at **`/about/`**; every other page is served at **`/pages/<slug>/`**.

| Field | Type | Meaning |
|-------|------|---------|
| `title` | string | Page title (falls back to the slug) |
| `title_en` | string | English title (optional) |
| `content_en` | string | English body as YAML (optional) |

```markdown
---
title: 关于
title_en: About Me
---

这里是页面正文。

For backwards compatibility with the previous storage, the English body may instead live in a sibling file blog/pages/<slug>.en.json containing {"content_en": "…"}. The frontmatter field wins; the sidecar is only read when it is absent. When both an English title and an English body are available, /pages/<slug>/ renders the English variant.

Talks (动态) — blog/talks/<unixtime>.md

Short "moments". The filename is the numeric id and doubles as the creation timestamp when create_time is missing.

Field Type Meaning
id integer Talk id; also the filename
create_time Unix seconds Sort key (newest first)
---
id: 1781603753
create_time: 1781603753
---

今天天气真好!在浏览器里测试动态功能。 😊

Friend links — blog/friends/<id>-<slug>.md

Field Type Meaning
id integer Numeric id, unique; the filename starts with it
title string Site name (falls back to the filename)
descr string Description
title_en / descr_en string English variants (optional)
avatar string Avatar image URL
url string Target link (defaults to #)
sort_order integer Ascending display order, then id
---
id: 2
title: Google
descr: 全球最大的搜索引擎
title_en: Google
descr_en: The world's largest search engine
avatar: https://www.google.com/favicon.ico
url: https://www.google.com
sort_order: 0
---

When you change a friend's title through the admin console, the file is renamed to keep the <id>-<slug> convention in sync.

Writing content from the admin console

The editors at /admin/posts/new/, /admin/pages/new/, /admin/talks/new/ and /admin/friends/new/ write exactly the same files, atomically (temp file + rename). Mutations invalidate the Markdown cache automatically, so a saved post is visible on the public site on the very next request. The POST /api/admin/reload endpoint still exists for content you edited by hand outside the process.


Features

Public site

  • Home (/) — card grid of active posts, newest first, with cover images and auto excerpts.
  • Posts (/posts/) — all active posts grouped by year; individual posts at /post/<slug>/.
  • Post detail — server-rendered body, table of contents generated from the heading anchors, tags, categories, cover, and the comment form when comments are enabled.
  • Tags (/tags/, /tags/:name/) and Categories (/categories/, /categories/:name/) — facet indexes with post counts, most used first.
  • Archives (/archives/) — posts flagged archived: true, grouped by year. Tag and category listings deliberately include archived posts too.
  • Talks (/talks/) — chronological "moments".
  • Friend links (/friends/).
  • Pages — /about/ plus /pages/<slug>/.
  • Status (/status/) — live process uptime, Node.js version, memory usage and content counts (posts, comments, talks, friends, registered visitors), plus the permission-gated visitor calendar.
  • Visit counters — every rendered HTML page counts as one visit, tracked separately for the blog and the personal card and written atomically to blog/data/visits.json (blog / profile / total / since). /status/ shows "blog X · card Y · Z total", the card footer shows its cumulative count, and /api/status returns the visits field.
  • Visitor identity (/auth/) — a reader enters an email address; a registered address receives a signed visitor cookie, an unknown one can submit a registration request that lands in the admin approval queue. Logging out works both as a plain HTML form post (POST /auth/logout, no JavaScript required) and as JSON (POST /api/auth/logout).
  • 2048 (/2048/) — the easter egg, complete with best-score persistence in the browser.
  • Search (/search/) — full-text search over titles, tags, categories and post bodies, with an in-process index. Works without JavaScript (plain GET form); js/search.js upgrades it to live results as you type, and Ctrl/Cmd+K focuses the field.
  • Personal card (/profile/, its own port) — a standalone, full-viewport page: a blurred night background with faint drifting lights, a frosted-glass panel, a neon-ringed avatar, gradient pill badges, and expandable navigation bars. It renders through its own layout (no site chrome) and needs no JavaScript — the panels are <details>. The card is served by a separate Express app on BMY_PROFILE_PORT (default 31000), not by the blog, so a reverse proxy can publish it on its own domain; / and /profile/ both render it. Content lives in the profile: block of _config.yml.
  • Bilingual content — per-post and per-page English title/body; the public UI strings have built-in defaults in src/site-config.ts and can be overridden per key under strings: in _config.yml.
  • SEO — per-route <title>, meta description and canonical link, plus /sitemap.xml and /rss.xml (rssLimit newest posts). Set url in _config.yml (or BMY_SITE_URL) so the canonical URLs and feed links are absolute.
  • Right-sized images — every image the site renders is served by the resize endpoint below, so a phone never downloads a desktop-sized photo.

Images: resizing, cropping and caching

Nothing is pre-generated and nothing is uploaded twice. Templates and the Markdown renderer emit /media URLs carrying a target size; GET /media (src/routes/media.ts) renders the source through sharp on first request, writes the result under blog/data/image-cache/, and streams it. Only the size the browser actually picks from a srcset is ever produced.

GET /media?src=/uploads/photo.jpg&w=800          # scale to 800 px wide
GET /media?src=/img/avatar.svg&w=160&fit=cover   # square crop
  • Markdown. ![](/uploads/a.jpg) is rewritten at render time into src/srcset/sizes covering 320–2000 px (src/lib/markdown.ts). A 2400 px original is not sent to a 360 px column. Remote URLs and data: URIs are left exactly as written.
  • Templates. mediaUrl() and mediaSrcset() are available to every view. Covers get a srcset matched to the masonry column width, and the drawer and about avatars request a 160/320 px square — a large photo avatar therefore ships only a few kilobytes at the size it is painted.
  • Cache key. The SHA-256 of the source path, its size and mtime, plus the requested size, fit and format. Editing an image in place is therefore picked up with no manual invalidation — the URL is unchanged but the ETag is not, and responses are immutable for a year. Unused entries are swept after 30 days; the whole directory is disposable.
  • Bounded on purpose. Only local files under /uploads, /avatars and /img are accepted (the decoded path is re-checked against those directories, so .. and %2e%2e cannot escape), widths snap up to a fixed ladder with a 2560 px ceiling, quality is fixed per format, and w/h are required — /media cannot be used as an open proxy or to mint unbounded cache entries.
  • Concurrency. Two simultaneous requests for the same variant run sharp once; files are written to a temporary name and renamed, so a reader never sees a half-written image.

Seamless navigation

Clicking an internal link does not reload the page. The document is fetched in the background and only the parts that actually differ are replaced — the content region, the page's own stylesheet, <title> and the description / Open Graph / canonical tags, and the sidebar's active highlight. The top app bar, the navigation drawer (including its scroll position and open state), the inline Material You palette and mdui's already-upgraded custom elements are left exactly as they were.

That is blog/public/js/pjax.js. It is progressive enhancement, not a rewrite of the rendering model:

  • Every URL still returns a complete document. Crawlers, curl and browsers with JavaScript disabled get everything, because the server does not know or care whether the client will patch the page afterwards. (An earlier version of this project was a client-rendered shell that fetched fragments; it was replaced for exactly this reason, and this feature deliberately does not reintroduce it.)
  • Any failure — network error, non-HTML response, a page without the content container — falls back to a normal navigation.
  • Links to /admin (its own shell), /profile/ (a standalone document), feeds, images, downloads, cross-origin URLs, target="_blank" and modified clicks (Ctrl/Cmd/Shift/middle) are all left to the browser.

The page-script contract

Because the content region is discarded and rebuilt on every navigation, a page script cannot capture elements at module scope — an ES module is evaluated only once, so a second visit would leave it bound to deleted nodes. Each page script in blog/public/js/ therefore exports:

export function init() {}    // query the DOM *here*, every time
export function destroy() {} // release listeners, timers and observers

init() is re-entrant, destroy() is safe when init() never ran, and anything bound outside the page's own subtree (document, window, timers, observers) is registered in init() and released in destroy().

pjax.js reads the list of scripts a page needs from the content container's data-pjax-scripts attribute (emitted by the layout), calls destroy() on the outgoing set, swaps the DOM, then imports and initialises the incoming set. window.__pjax.importing is set around each import so a module that has not been evaluated yet skips its own auto-init — that keeps a plain page load (where the layout's <script type="module"> tags do the initialising) and a pjax navigation from initialising anything twice.

Back and forward are handled too: history entries carry their scroll offset, and the parsed documents are cached briefly so going back is instant.

Two details of the shell are worth knowing if you touch this code:

  • The window is not the scroller. The shell is a 100vh flex column and .content-container is the scroll box (overflow: auto), which is also what mdui's top app bar is bound to via scroll-target. Scrolling window there- fore does nothing at all. pjax.js moves the container instead, and returns it to the top before the swap — otherwise a page entered from halfway down another one keeps the elevated (opaque) app bar it inherited, even though the new page is at its top where the bar is meant to blend into the background.
  • pushState happens before the swap, not after. Page scripts read window.location — comments.js keys its request on the path — so updating the URL afterwards meant a freshly navigated post fetched the comment list for the page it had just left, and only a manual reload showed its comments.

Comments

  • One JSON file per page under blog/data/comments/; writes are atomic and the reader is mtime-cached.
  • The file is keyed by the canonical page path — query/fragment dropped, duplicate slashes collapsed, trailing slash removed — so /post/x and /post/x/ share one thread. That matters because a post genuinely has both URLs: Express treats the trailing slash as optional, and the static build's dist/post/x/index.html is served at the slash form (with a redirect from the bare one). Keying on the raw window.location.pathname split a post's comments in two, so a comment posted after a pjax navigation (which keeps the URL as linked) was missing after a reload (which lands on the slash form). Threads still stored under the old trailing-slash key keep loading, and are rewritten under the canonical key on the next post — see canonicalCommentPath() in src/store/comments.ts.
  • Fields: id, nick, mail, comment, link, ua, avatar, url, createTime.
  • mail, ua and url are server-side only: GET /api/comments returns an explicit field list (id, nick, comment, link, avatar, createTime). mail is shown in the admin console and nowhere else.
  • Server-side validation: nickname ≤ 80, email ≤ 200 and format-checked, body ≤ 5000, url must be a site path (/…, not //… or a scheme) of at most 200 characters, user agent truncated to 500 characters (the original implementation had no limits at all). The optional link accepts http(s)://…, a bare host (example.com) or a host with a path, and a bare host is promoted to https:// — the field is labelled 网站(可选)and the natural thing to type there is a domain, which the stricter rule used to answer by discarding the whole comment. Everything else (javascript:, data:, mailto:, //host/…) is still refused, because the value becomes an href in the admin console. See normaliseCommentLink().
  • A refused submission is explained under the form, in the server's own words (邮箱格式不正确, 昵称过长(最多 80 字符), 评论太频繁了,请 47 秒后再试). It used to be a snackbar reading 提交失败 for every single case, which told the visitor nothing and left them with nothing to correct. The required fields, the email shape and the website shape are also checked client-side, so those mistakes never cost a round trip.
  • Posting is rate limited to 10 comments per IP per minute. Comment moderation (list across all pages, delete) lives in the admin console.

Comment privacy

The form carries a switch labelled 不展示我的信息, between the comment body and the submit button, with a short notice under it. It is a real behaviour, not a decoration:

  • With the switch off, a submission includes nick, mail, comment, link, url (the page the comment belongs to — it is also the storage key) and ua (navigator.userAgent), the only value the page collects rather than the visitor typing it.
  • With the switch on, comments.js sends an empty ua. The server stores an empty value rather than guessing the browser from the request headers, so turning the switch on genuinely withholds the information instead of hiding it.
  • The switch is per submission and resets after a successful post; nothing about it is persisted in the browser or the session, and the server never sets a cookie to remember it.
  • Nothing a visitor submits is handed back to other visitors beyond what the comment displays. GET /api/comments returns an explicit field list — nick, comment, link, avatar, createTime — so the stored mail and ua stay server-side (visible in the admin console only) instead of being published to anyone who opens DevTools.

If you change either string, keep the notice accurate — it is the only place a visitor is told what is collected. The wording lives in strings: in _config.yml (commentPrivacyToggle, commentPrivacyNotice).

Search

/search/ is a normal server-rendered page, so a query is a shareable URL and the form works with JavaScript disabled. It queries titles, English titles, tags, categories and post bodies.

  • Index: built in-process from the same mtime-cached Markdown scanner the rest of the site uses, and memoised on the set of file mtimes — editing a post invalidates it automatically, with no rebuild step or search daemon. POST /api/admin/reload drops it explicitly.
  • Matching is substring-based, not word-based. Chinese has no spaces, so a Latin-style tokeniser would fail on exactly the queries this blog receives; Latin queries still work because matching is case-insensitive and a multi-word query is split on spaces for AND-style ranking.
  • Ranking: title matches outweigh English titles, which outweigh tags/categories, which outweigh body hits. Archived posts are still findable but rank 40% lower. A post matching every token ranks above one matching only some.
  • Snippets are cut around the first match and the matched terms are wrapped in <mark>. Highlighting is XSS-safe by construction: text is HTML-escaped before the <mark> tags are inserted, so a post containing <script> can never inject markup. The client-side live search never uses innerHTML either — it rebuilds the highlight with createElement + textContent.
  • Rate limit: 60 requests per IP per minute on /api/search.
  • SEO: the results page carries noindex, follow, since per-query result pages are thin duplicates; the posts they link to stay indexable.

Admin console

Every page under /admin/ is a complete server-rendered document, not a client-side SPA:

Page What it does
/admin/login/ Password login; becomes a two-step form when TOTP is enabled
/admin/setup/ First-run wizard that creates the admin account (only while none exists)
/admin/dashboard/ Counts (active/archived posts, comments, tags, talks) and the five most recent posts
/admin/posts/ All posts incl. archived; create, edit, archive/unarchive, delete
/admin/posts/new/, /admin/posts/:slug/edit/ Post editor: slug, title, date, cover, comma-separated tags/categories, Chinese and English bodies
/admin/pages/, /admin/pages/new/, /admin/pages/:slug/edit/ Page list and editor (upsert by slug)
/admin/talks/, /admin/talks/new/ Publish, edit and delete talks
/admin/friends/, /admin/friends/new/, /admin/friends/:id/edit/ Friend-link list and editor
/admin/comments/ Every comment across every page, newest first, with delete
/admin/visitors/ Visitor management (new in the rewrite): approve pending registration requests, grant or revoke the calendar permission, add or remove approved addresses
/admin/security/ Change username/password, enable or disable TOTP 2FA (QR/secret + 6-digit confirmation)
/admin/imghost/ Image-host configuration (SFTP or S3, incl. TLS/mTLS) and a connection test
/admin/backup/ Export the whole site as one JSON file and import a backup

Image hosting

Uploaded avatars are resized to 512×512 WebP by sharp and written to blog/public/avatars/. Two remote providers are supported, configured in /admin/imghost/ or entirely through environment variables:

  • S3-compatible (MinIO, AWS S3, Cloudflare R2, …) via @aws-sdk/client-s3, including custom CA and mutual TLS (client certificate + key).
  • SFTP via ssh2-sftp-client (host, port, username, private-key path, remote directory).

Both providers support a filename template ({yy}, {mm}, {dd}, {file_extension}, {original}); path separators in a rendered name are neutralised and templates without per-upload uniqueness get a short random suffix so two uploads on the same day cannot overwrite each other. When S3 is enabled, freshly generated avatars are mirrored to the bucket so multiple instances serve the same file.

Security

  • Passwords use scrypt (N=16384, r=8, p=1, 64-byte key, 16-byte random salt) with constant-time verification. Records written by the old implementation (AES-256-CBC + EVP_BytesToKey) are still accepted and are transparently re-hashed with scrypt on the next successful login.
  • Sessions are base64url(payload).HMAC-SHA256(payload) tokens carrying username, issue time and a random nonce. Logout really revokes (the token is blacklisted) and changing the password or username bumps a revocation epoch that invalidates every outstanding token — the original only dropped the cookie and left the token valid for 7 days.
  • The session secret comes from BMY_SESSION_SECRET or, when unset, is generated once and persisted to blog/data/secret.json (mode 0600). The old build shipped a hard-coded default secret, which let anyone forge an admin token.
  • The admin cookie is HttpOnly + SameSite=Strict with a 7-day TTL (configurable with BMY_SESSION_TTL, in seconds); the visitor cookie is HttpOnly + SameSite=Lax. The token itself is signed with the session secret, so a token from one deployment is useless on another.
  • TOTP 2FA — RFC 6238, HMAC-SHA1, 6 digits, 30-second steps, ±1 step of clock skew, compatible with Google Authenticator / Authy / 1Password. Login becomes a two-step handshake: the password step returns a single-use 5-minute temp token, and a wrong code burns that token so codes cannot be brute-forced against one handshake. Enabling 2FA requires proving a working code first. The active secret is never returned by the API — only a pending, unconfirmed one.
  • Origin check on every state-changing request (anything other than GET/HEAD/OPTIONS): a cross-origin Origin header is rejected with 403. This complements SameSite=Strict and also protects the Authorization: Bearer path.
  • Rate limiting is enforced in-process, keyed by client IP and route: admin login and setup 30/min, comment posting 10/min, /api/auth/check 20/min, /api/auth/register 5/min, avatar uploads one per 30 seconds. Exceeding a limit returns HTTP 429 with a Retry-After header.
  • Strict Content-Security-Policy with no third-party CDN and no inline scripts (script-src 'self'; style-src 'self' 'unsafe-inline' only because mdui sets inline style attributes). Also X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy, base-uri 'self', form-action 'self', frame-ancestors 'none'. X-Powered-By is disabled.
  • isSafeSlug() guards every slug-derived path (no /, no \, no NUL, no leading dot, ≤ 200 characters), and the backup importer rebuilds all filenames from validated slugs — so neither a crafted URL nor a crafted backup can write outside the content directories.
  • Admin usernames are restricted to [A-Za-z0-9_.-]{1,64}, passwords must be ≥ 6 characters, and a small list of leaked/weak passwords (bmy2025, admin, password, …) is rejected at setup.
  • Every admin page and every /api/admin/* route except login/setup is authentication-gated (requireAdmin for the JSON API, requireAdminPage for HTML). The old implementation exposed its /api/admin/ssr/* fragment endpoints completely unauthenticated.

Known limitation: visitor email verification

Without an SMTP provider there is no way to prove that a visitor controls the address they type in. /api/auth/check therefore grants a signed visitor cookie to any known (already approved) address on request — it verifies registration, not ownership. The code documents this in src/auth/visitor.ts. Treat the visitor system as a light permission gate (it exists to protect the calendar), not as an authentication mechanism: everything genuinely sensitive is behind the admin login. If you need real proof of ownership, add an SMTP-based confirmation flow before enabling it for untrusted users.


Configuration

Configuration is split in two, like Hexo plus twelve-factor:

What Where Version-controlled?
Site configuration — name, tagline, description, keywords, author, language, avatar, theme colour, navigation menu, feature switches, excerpt lengths, UI wording config.yml (master, tracked) → copied on boot to _config.yml (working copy, untracked; the only file the admin writes) Master yes
Deployment configuration — port, bind address, session secret, admin bootstrap, S3 credentials environment variables (.env / BMY_*) No

The split is deliberate: the first is content-ish and belongs in the repository next to the code; the second differs per machine and includes secrets, so it must never be committed.

Site configuration — _config.yml

One file, comment-annotated, at the repository root. Only the keys you want to change need to be present; everything else falls back to built-in defaults, so a partial or even missing file still boots.

title: Blog Material You      # site name (sidebar, app bar, <title>)
subtitle: 暂无简介             # tagline under the site name
description: 一个使用 Node.js 与 Material You 构建的博客   # meta description
keywords: []                  # meta keywords
author: ''                    # meta author
language: zh-CN               # <html lang="…">
url: ''                       # canonical base URL for canonical/sitemap/RSS

avatar: /img/avatar.svg
copyright: © 2025 Blog Material You
github: https://github.com/yourname/blog
theme:
  color: '#ff006a'            # Material You seed colour

menu:                         # sidebar / navigation
  - text: 首页
    icon: /icon/home.svg
    route: /

features:                     # turn whole features off
  comments: true
  calendar: true
  search: true
  easterEgg: true
  rss: true
  sitemap: true

excerpt:
  length: 200                 # home-card excerpt (no cover)
  lengthWithCover: 120        # home-card excerpt (with cover)
rssLimit: 20

strings: {}                   # override any UI string, e.g. {page_posts: 博客文章}
  • No restart needed — the file is read with an mtime cache, so an edit takes effect on the next request.
  • Feature switches are honoured at request time: turning search, rss, sitemap or easterEgg off removes the corresponding route/UI, and calendar: false hides the status-page calendar card.
  • strings overrides any key from the built-in dictionary (see DEFAULT_STRINGS in src/site-config.ts); a key you do not override keeps its built-in value.
  • Editable from the admin console — /admin/settings/ writes back into this file. The edit is line-based and validated (re-parsed before writing, atomic rename), so the comments above survive and a bad value is rejected instead of corrupting the file.
  • theme.color is the site's theme colour. It must be hex (#rgb, #rrggbb or #rrggbbaa) — that is the only input mdui's palette generator accepts, and the admin form rejects anything else rather than letting it fail quietly. It seeds the Material You palette and is emitted as <meta name="theme-color"> for the browser UI.
  • The Material You palette is generated on the server (src/lib/color-scheme.ts) and inlined into each page, mirroring what mdui's setColorScheme() does in the browser. That means the correct colours are present in the first paint — no flash of the default purple — and the site stays themed with JavaScript disabled.
  • BMY_CONFIG_FILE points the server at a different file, which is handy for keeping per-deployment config outside the checkout.
  • url is the one value an environment variable may override: an explicitly set BMY_SITE_URL wins over _config.yml, so a container can be re-pointed without editing a tracked file.
  • A malformed YAML file is logged loudly and falls back to defaults rather than taking the site down, so you can still reach the admin area and fix it.

Personal card — the profile: block

/profile/ is a standalone page (it does not use the site shell), so its content is configured separately:

profile:
  enabled: true               # the only switch for this page
  accent: ''                  # empty = follow theme.color; set a hex to pin it
  avatar: /img/avatar.svg
  greeting: Hi, I am
  name: Your Name
  tagline: 这里是我的名片与导航
  background: ''              # optional photo; blurred by CSS. Empty = built-in night gradient
  tags:                       # one array per row of pill badges
    - [摄影, 旅行, 美食]
    - [编程, 游戏]
  buttons:                    # the right-hand bars
    - { text: Github, icon: github, href: https://github.com/yourname, action: link }
    - { text: Blog, icon: blog, href: /posts/, action: link }
    - text: More
      icon: more
      action: expand          # expand shows a ⌄ and opens a panel
      items:
        - { text: 归档, href: /archives/ }
    - { text: Music (From NetEase), icon: music, action: expand, music: true }
  music:
    audio: ''                 # direct audio URL → native <audio> player
    embed: ''                 # or an embed URL (NetEase outchain) → <iframe>
  • action: link renders a › chevron and navigates; action: expand renders a ⌄ chevron and opens a panel.
  • Icons are inline SVG, so no icon font or CDN is involved: github (<>), blog, more (branch), music, link, mail, rss.
  • No JavaScript: the panels are <details> (with the name attribute for a native accordion where supported).
  • Setting music.embed or music.audio also adds exactly that origin to the page's CSP frame-src / media-src, so the strict policy does not have to be loosened globally.
  • accent defaults to the site theme colour, so changing theme.color re-themes this page too. Set it to a hex value (e.g. '#4da3ff') to pin the card's neon ring, badges and icons to a fixed colour regardless of the theme.
  • To surface the page in the sidebar, add it to menu: — it is deliberately not there by default.

Deployment configuration — environment variables

Copy .env.example to .env (it is gitignored) or pass the variables to Docker. Everything is optional — the defaults are sensible for local use.

Service

Variable Default Meaning
BMY_PORT 30999 HTTP listen port of the blog
BMY_PROFILE_PORT 31000 HTTP listen port of the standalone personal card
BMY_CONFIG_MASTER ./config.yml Master config file, synced by mtime into BMY_CONFIG_FILE on boot
BMY_DIST_DIR $BMY_BLOG_DIR/dist Static build output directory (npm run build)
BMY_AUTO_BUILD 1 Set to 0 to disable the automatic rebuild after admin config writes
BMY_HOST 0.0.0.0 Listen address
BMY_SITE_URL (empty) Overrides url in _config.yml. Public base URL, e.g. https://blog.example.com, used for canonical links, sitemap.xml and rss.xml; without it those URLs are relative
BMY_TRUST_PROXY 0 Set to 1 behind a reverse proxy so rate limiting keys on the real client IP from X-Forwarded-For
BMY_MAX_UPLOAD_MB 100 JSON/URL-encoded request-body limit, in MB (backup import)
NODE_ENV development production enables EJS template caching and disables development warnings

Admin account

Variable Default Meaning
BMY_ADMIN_USER admin Admin username used when bootstrapping from the environment
BMY_ADMIN_PASS (empty) When set and no admin account exists, creates the account at startup. It never overwrites a password you changed in the UI
BMY_SESSION_SECRET (empty) HMAC key for admin session tokens. When empty, a random 32-byte secret is generated once and stored in blog/data/secret.json
BMY_SESSION_TTL 604800 Session lifetime in seconds (7 days)

Directories

Variable Default Meaning
BMY_BLOG_DIR ./blog Content + static + runtime-state root
BMY_DATA_DIR $BMY_BLOG_DIR/data Runtime state (credentials, comments, visitors, TOTP, settings, sessions, image-cache/)
BMY_PUBLIC_DIR $BMY_BLOG_DIR/public Static assets (css/js/icon/img/vendor/avatars/uploads)
BMY_VIEWS_DIR ./views EJS template directory
BMY_CONFIG_FILE ./_config.yml Path to the site configuration file (see above)

Image host (S3 / SFTP)

Variable Default Meaning
BMY_IMGHOST_PROVIDER (empty) s3 or sftp — setting it also enables image hosting
BMY_S3_ENDPOINT (empty) S3 endpoint, e.g. http://minio:9000. A complete set of the four S3 credentials enables S3 on its own
BMY_S3_BUCKET (empty) Bucket name
BMY_S3_ACCESS_KEY (empty) Access key
BMY_S3_SECRET_KEY (empty) Secret key
BMY_S3_REGION (empty) Region (AWS); empty means us-east-1
BMY_S3_PREFIX (empty) Optional key prefix inside the bucket
BMY_S3_PUBLIC_URL_BASE (empty) Public URL base for served objects; defaults to $BMY_S3_ENDPOINT/$BMY_S3_BUCKET
BMY_S3_INSECURE 0 1 skips TLS certificate verification (self-signed certificates)
BMY_S3_SSL_CA (empty) CA bundle path (inside the container) used to verify the S3 server
BMY_S3_SSL_CERT (empty) Client certificate path for mutual TLS
BMY_S3_SSL_KEY (empty) Client private key path for mutual TLS. Mutual TLS needs certificate + key in one file, so BMY_S3_SSL_CERT and BMY_S3_SSL_KEY usually point at the same PEM bundle

SFTP settings have no environment variables — configure them on /admin/imghost/; they are stored in blog/data/imghost.json. Environment variables always win over the stored file and are never written back, so a deployment configured entirely through env vars keeps working when the settings form is saved.

Migration only

These are read by scripts/import-legacy-mariadb.mjs, not by the server:

Variable Default Meaning
BMY_DB_URL (empty) mariadb://user:pass@host:3306/dbname — takes precedence over the individual values
BMY_DB_HOST / BMY_DB_PORT (empty) / 3306 TCP host and port
BMY_DB_SOCKET (empty) Unix socket path (used when no host is set)
BMY_DB_NAME blogyou Database name
BMY_DB_USER / BMY_DB_PASS blogyou / blog-db-pass-2025 Database credentials

HTTP API

Response envelope

Every JSON endpoint except /api/health uses the same envelope:

{ "errno": 0,  "data": { } }
{ "errno": -1, "errmsg": "Post not found: foo" }

errno: 0 means success and the payload is under data; errno: -1 means failure and the human-readable reason is errmsg. The HTTP status carries the semantics as well (400 validation, 401 unauthenticated, 403 forbidden, 404 missing, 409 conflict, 429 rate limited, 500 unexpected). /api/health is deliberately plain for load balancers and returns {"status":"ok","server":"nodejs","version":"2.0.0"} with a 200. Field names are camelCase throughout (the pre-rewrite API mixed snake_case and camelCase and leaked session_secret, admin_user and admin_pass from /api/config).

Public API

Method Path Description
GET /api/health Liveness probe (plain object, no envelope)
GET /api/site Site settings, navigation menu and the full UI string dictionary — never any credential material
GET /api/status Uptime, start time, Node.js version, platform, memory, counts (posts, comments, talks, friends, visitors), and the visits page-view counters (blog / profile / total)
GET /api/posts Active (non-archived) posts, newest first, as summaries with excerpts
GET /api/posts/:slug One post: metadata, raw Markdown, server-rendered html, htmlEn, and toc (404 if missing)
GET /api/tags Tag index with post counts, most used first
GET /api/tags/:name Posts carrying that tag (includes archived posts)
GET /api/categories Category index with post counts
GET /api/categories/:name Posts in that category (includes archived posts)
GET /api/archives Archived posts grouped by year
GET /api/pages/:slug One page: metadata, raw Markdown, rendered html, toc (404 if missing)
GET /api/talks All talks, newest first, each with rendered html
GET /api/friends Friend links in display order
GET /api/search?q=<query>&limit=<n> Full-text search. Returns {query, tokens, total, limit, results}; each result carries plain title/snippet plus pre-escaped titleHtml/snippetHtml with <mark>. An empty q returns an empty result set, not an error. limit defaults to 20 (max 50)
GET /api/comments?path=<url> Comments for a page, oldest first. Any URL form of the page works (/post/x and /post/x/ are the same thread). Each comment has id, nick, comment, link, avatar, createTime — mail and ua are never returned. Add &type=count to get {count} only
POST /api/comments Post a comment. Body {nick, mail, comment, url, link?, ua?, avatar?}; url must be a site path (/post/x — a trailing slash is accepted and normalised away), link may be https://… or a bare example.com (promoted to https). A refusal is 400 with {errno: -1, errmsg} — errmsg is written for the visitor and the form shows it as-is — or 429 with {errno: -1, code: "rate_limited", retryAfter, errmsg} plus a Retry-After header
POST /api/auth/check {email} → {registered, permissions, name}; sets the signed visitor cookie when the address is registered
POST /api/auth/register {email, name?} → queues a registration request for admin approval (409 if already registered or already pending)
POST /api/auth/logout Clears the visitor cookie
POST /auth/logout HTML logout used by the auth page's plain form, so it works without JavaScript; clears the visitor cookie and redirects (303) to /auth/
GET /api/calendar Calendar events. Requires a visitor cookie with the calendar permission (401 without a cookie, 403 without the permission)
POST /api/upload-avatar Upload a comment avatar. Accepts multipart (file), JSON {image} or a raw base64 body; returns {url}. Max 5 MB, one upload per 30 seconds

curl examples:

# Health
curl http://localhost:30999/api/health
# → {"status":"ok","server":"nodejs","version":"2.0.0"}

# Post summaries
curl http://localhost:30999/api/posts

# One post, rendered server-side
curl http://localhost:30999/api/posts/2025-11-23

# Post a comment
curl -X POST http://localhost:30999/api/comments \
  -H 'Content-Type: application/json' \
  -d '{"nick":"Alice","mail":"alice@example.com","comment":"Nice post!","url":"/post/2025-11-23"}'

# Visitor check (stores the cookie in a jar)
curl -c cookies.txt -X POST http://localhost:30999/api/auth/check \
  -H 'Content-Type: application/json' -d '{"email":"alice@example.com"}'
curl -b cookies.txt http://localhost:30999/api/calendar

# Upload an avatar
curl -X POST http://localhost:30999/api/upload-avatar -F 'file=@avatar.png'

Admin API

Mounted at /api/admin. All routes except POST /login, GET|POST /setup require an authenticated admin session; send it either as the admin_token cookie or as an Authorization: Bearer <token> header. Unauthenticated requests get 401 {"errno":-1,"errmsg":"Unauthorized"}.

Method Path Description
POST /api/admin/login {username, password} → session + {token, user}. With 2FA enabled it returns {step:2, tempToken} instead; send {step:2, tempToken, totp} to finish
GET /api/admin/setup {setupDone, user} — is the first-run wizard still needed?
POST /api/admin/setup {action:"init", user, password} first run; {action:"change", oldPassword, newPassword, newUser?} afterwards
POST /api/admin/logout Revokes the current token and clears the cookie
GET /api/admin/posts Every post incl. archived ones, with bodies
POST /api/admin/posts Create ({slug, title, titleEn, content, contentEn, date, tags, categories, cover, archived})
PUT /api/admin/posts Update an existing post (same body)
PATCH /api/admin/posts {slug} → toggle the archived flag, returns {slug, archived}
DELETE /api/admin/posts?slug= Delete the post file
GET /api/admin/pages All pages
PUT /api/admin/pages Upsert a page ({slug, title, titleEn, content, contentEn})
DELETE /api/admin/pages?slug= Delete a page file
GET /api/admin/talks All talks
POST /api/admin/talks Publish a talk ({content})
PUT /api/admin/talks Update a talk ({id, content})
DELETE /api/admin/talks?id= Delete a talk
GET /api/admin/friends All friend links
POST /api/admin/friends Add a friend link ({title, url, descr?, titleEn?, descrEn?, avatar?, sortOrder?})
PUT /api/admin/friends Update a friend link ({id, …})
DELETE /api/admin/friends?id= Delete a friend link
GET /api/admin/comments Every comment across every page, newest first
DELETE /api/admin/comments?id= Delete a comment by id
GET /api/admin/visitors {visitors, pending, permissions} — approved addresses, the approval queue and the permission catalogue
POST /api/admin/visitors Create or update an approved visitor ({email, permissions, name?})
DELETE /api/admin/visitors?email= Remove an approved visitor
POST /api/admin/visitors/approve Approve a pending request and grant permissions ({email, permissions})
DELETE /api/admin/visitors/pending?email= Reject a pending request
GET /api/admin/totp 2FA state. secret/provisioningUri are only ever the pending secret, never the active one
POST /api/admin/totp {action:"start"} generate a secret, {action:"verify", code} confirm it, {action:"disable"} turn 2FA off
GET /api/admin/imghost Image-host config with secret_key masked as ********
PUT /api/admin/imghost Save the config (an empty or masked secret_key keeps the stored value)
POST /api/admin/imghost {action:"test"} connection test, or {action:"upload", image, filename?, key?} (base64 image)
GET /api/admin/export Download the whole site as one JSON document (blog-backup-YYYYMMDD-HHMMSS.json)
POST /api/admin/import Restore a backup; the body is the raw JSON document
POST /api/admin/reload Drop every cached Markdown parse and return the new counts

curl examples:

# Log in and keep the session cookie
curl -c admin-cookies.txt -X POST http://localhost:30999/api/admin/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"your-password"}'
# → {"errno":0,"data":{"token":"…","user":"admin"}}

# List every post (cookie auth)
curl -b admin-cookies.txt http://localhost:30999/api/admin/posts

# …or with a bearer token
curl -H 'Authorization: Bearer <token>' http://localhost:30999/api/admin/posts

# Create a post
curl -b admin-cookies.txt -X POST http://localhost:30999/api/admin/posts \
  -H 'Content-Type: application/json' \
  -d '{"slug":"hello-world","title":"Hello World","date":"2025-11-23",
       "tags":"Notes","categories":"Blog","content":"# Hi\n\nFirst post."}'

# Export a backup
curl -b admin-cookies.txt http://localhost:30999/api/admin/export -o backup.json

# Restore a backup
curl -b admin-cookies.txt -X POST http://localhost:30999/api/admin/import \
  -H 'Content-Type: application/json' --data-binary @backup.json

# Log out (revokes the token server-side)
curl -b admin-cookies.txt -X POST http://localhost:30999/api/admin/logout

The Origin guard rejects state-changing requests that carry a mismatched Origin header. curl sends none, which is fine; browser clients must call the API same-origin.


Migrating from the old MariaDB version

The rewrite keeps the same on-disk contract for content, so an existing deployment can be moved over with one script. It reads the old MariaDB tables through the mariadb (or mysql) command-line client with --xml output — no database driver is required, and it reuses the application's own writers, so the output format can never drift from what the server expects.

# Preview everything without writing anything
node scripts/import-legacy-mariadb.mjs --dry-run

# Real migration
node scripts/import-legacy-mariadb.mjs

# Skip the `config` table (admin credentials, TOTP state, image-host settings)
node scripts/import-legacy-mariadb.mjs --skip-config
Flag Effect
--dry-run Prints every post/page/talk/friend/comment it would write and a summary — touches nothing
--skip-config Leaves admin_creds, totp_state and imghost_config alone (useful when you want to keep the new server's own settings)

Connection settings come from the environment variables listed above.

What it migrates

Source (MariaDB) Destination
posts blog/posts/<slug>.md — title, date, tags, categories, cover, archived, title_en, content_en frontmatter
pages + page_content blog/pages/<slug>.md; the English body from page_content is folded into content_en
talks blog/talks/<id>.md with id / create_time frontmatter
friends blog/friends/<id>-<slug>.md with sort_order
comments blog/data/comments/<key>.json, one file per page URL
emails, pending_registrations blog/data/visitors.json (approved addresses with permissions + the pending queue)
calendar_events blog/data/calendar.json
config: admin_creds blog/data/admin.json — copied verbatim
config: totp_state blog/data/totp.json
config: imghost_config blog/data/imghost.json

Missing tables are simply skipped, so a partial old install still migrates. Run it with --dry-run first and compare the summary against the old database.

The admin password is preserved

admin_creds holds the legacy AES-256-CBC verifier and is copied across unchanged. src/store/credentials.ts detects that format, still verifies the old password, and re-hashes it with scrypt on the first successful login ([credentials] legacy password record upgraded to scrypt). You do not need to reset anything.

If the legacy login fails for any reason, run npm run passwd-reset -- --clear and create a fresh account at /admin/setup/. The original record is backed up next to itself first, so a mistaken reset is recoverable.

Legacy visitor data is also imported automatically: the first time blog/data/visitors.json is read, the old blog/data/auth/emails.json and blog/data/auth/pending.json files are folded in if they exist.


Backup and Restore

The admin console's 数据备份 page at /admin/backup/ exports the entire site as one JSON document — the same "one file" contract as the old implementation, but the sections now mirror the on-disk layout instead of SQL tables.

{
  "app": "blog-material-you",
  "version": 2,
  "exportedAt": 1763900000,
  "content": { "posts": [], "pages": [], "talks": [], "friends": [] },
  "data": { "comments": [], "visitors": {}, "site": {}, "calendar": [],
            "admin": {}, "totp": {}, "imghost": {} }
}
  • content.posts / content.pages keep the raw frontmatter and body, so unknown frontmatter keys survive a round-trip. content.friends drops only the internal absolute file path.
  • data.comments, data.visitors, data.site and data.calendar cover comments, the visitor registry and approval queue, site settings (including UI-string overrides) and calendar events.

⚠️ The backup file is a secret. data.admin contains the admin password verifier (scrypt salt + hash), data.totp contains the TOTP shared secret, and data.imghost contains the S3/SFTP credentials including the secret key. Never commit it, never serve it unauthenticated, and delete it once you no longer need it. The exported download is authenticated, but the file you save is not protected afterwards.

importBackup() in src/lib/backup.ts accepts {includeCredentials: false} to restore content only and leave credentials, the TOTP secret and image-host keys untouched. The admin console always performs a full restore.

Import is destructive, matching the old import: a section present in the payload replaces the existing data, while an absent section leaves the current data alone. Posts, pages, talks, friends and comments are cleared before being rewritten; TOTP, credentials and image-host settings are overwritten when the corresponding section is present. Malformed individual entries are skipped with a warning rather than aborting the whole restore, and all filenames are rebuilt from validated slugs, so a crafted backup cannot write outside the content directories.

Via the admin page: open /admin/backup/, download the export, and upload the file to restore. Via the API:

# Export
curl -b admin-cookies.txt http://localhost:30999/api/admin/export -o backup.json

# Import (restores everything, including credentials)
curl -b admin-cookies.txt -X POST http://localhost:30999/api/admin/import \
  -H 'Content-Type: application/json' --data-binary @backup.json
# → {"errno":0,"data":{"summary":{"posts":12,"pages":3,…}}}

Because a backup restores credentials and the TOTP secret, importing a backup taken before you enabled 2FA will disable 2FA again — keep the current TOTP secret at hand before restoring on a live server.


Deployment

Static build + API server (optional)

Beyond the default single-process SSR, the site can also be deployed as "static pages + an API server":

npm run build       # render every public page + copy assets → blog/dist/
npm run serve:api   # serve /api /admin /media and static assets only — no SSR pages
  • blog/dist/ is a self-contained static site, deployable on any static host;
  • the API server provides the dynamic parts: /api/*, /admin/*, /media (on-demand resizing) plus /css /js /icon /img /vendor /avatars /uploads. Point page traffic at the static host and everything else at the API server in your reverse proxy;
  • every admin write to _config.yml automatically triggers a static rebuild (debounced 1.5 s; rapid saves coalesce into one build), and npm run serve:api builds once at startup. Set BMY_AUTO_BUILD=0 to disable (e.g. when CI owns the build);
  • deliberately not baked into the static output: /api, /admin (dynamic), /media (resize on demand — proxy it to the API server), and /auth/ (renders the visitor's session state).

Docker

The image is a single self-contained container: node:24-alpine, dependencies installed with npm ci --omit=dev, scripts/sync-assets.mjs run at build time, no database server, no nginx, no Python, no compiler. It runs as the unprivileged node user, exposes 30999, and declares a HEALTHCHECK that fetches /api/health every 30 seconds.

docker compose up -d --build
docker compose logs -f blog

The image chowns _config.yml to the node user so the admin settings page can write to it. If you would rather manage the configuration outside the container, bind-mount it read-only — saving then reports a clear error instead of silently doing nothing.

Volume layout

blog/ splits into two things you should treat differently:

Path Nature Recommended volume
blog/posts, blog/pages, blog/talks, blog/friends Content — version it, back it up, edit it from the host bind mounts for each directory
blog/data Runtime state — credentials, comments, visitors, TOTP, settings, sessions a named volume (must persist; losing it loses the admin account and all comments)
blog/public/avatars, blog/public/uploads Uploaded media bind mount or a volume
blog/public/vendor Generated from node_modules at build/boot time do not mount over it
services:
  blog:
    build: .
    ports:
      - "8000:30999"
    volumes:
      - blog_data:/app/blog/data          # state — named volume
      - ./blog/posts:/app/blog/posts      # content — bind mounts
      - ./blog/pages:/app/blog/pages
      - ./blog/talks:/app/blog/talks
      - ./blog/friends:/app/blog/friends
      - ./blog/public/avatars:/app/blog/public/avatars
    restart: unless-stopped
volumes:
  blog_data:

Behind a TLS reverse proxy

Put nginx/Caddy/Traefik in front for HTTPS (the app speaks plain HTTP on one port):

  1. Set url in _config.yml (or the BMY_SITE_URL environment variable) to the public https:// address, otherwise canonical links, sitemap.xml and rss.xml carry relative URLs.
  2. Set BMY_TRUST_PROXY=1 so Express trusts X-Forwarded-For and rate limits key on the real client IP instead of the proxy's. Without it, one abusive client can exhaust the limit for everyone.
  3. Forward both Host and X-Forwarded-Proto, and allow request bodies at least as large as BMY_MAX_UPLOAD_MB.
  4. Cookie Secure flag: session and visitor cookies are marked HttpOnly and SameSite=Strict/Lax, but not Secure — over plain HTTP a Secure cookie would never be sent, so the code leaves it off. If you terminate TLS at the proxy and want the flag, add secure: true in setSessionCookie() (src/auth/session.ts) and setVisitorCookie() (src/auth/visitor.ts) and serve the app exclusively over HTTPS. Until then, keep the app off the public internet over plain HTTP.
  5. The personal card runs on its own port (BMY_PROFILE_PORT, default 31000). The card and the blog share the same content and blog/data, so both ports can sit behind the proxy as-is; if the card gets its own domain, add a dedicated server/location block forwarding to that port (also forwarding Host and X-Forwarded-Proto). The card has no cookies or sessions, so its proxy configuration can be simpler than the blog's.

Scaling

The app is stateless with respect to in-process caches (Markdown parses and JSON stores are re-read when files change), so you can run several instances against shared content — but two caveats:

  • Rate limiting is per process. Each instance counts independently, so N instances allow N× the configured rate. A multi-instance deployment needs a limit at the proxy level (limit_req in nginx) as the real control; the in-process limiter is defence in depth.
  • Sessions live in memory. Tokens are signed with BMY_SESSION_SECRET, so set it explicitly to the same random value on every instance; the in-process revocation registry and two-step-login temp tokens are not shared, meaning a logout on one instance does not immediately invalidate the token on another (the revocation epoch is bumped per process only). Pin admin traffic to one instance, or shorten BMY_SESSION_TTL, if that matters to you.
  • Content and blog/data must be on shared storage (a mounted volume) for every instance; S3 image hosting keeps uploaded media consistent across instances.

Operations

  • Reset a lost admin password — npm run passwd-reset. Passwords are stored as one-way verifiers (scrypt, or the legacy AES record), so they can never be read back out of blog/data/admin.json — only replaced. The command runs offline against the same data directory the server uses:

    npm run passwd-reset                        # interactive: set a new password
    npm run passwd-reset -- --clear             # delete the account, then use /admin/setup/
    npm run passwd-reset -- --password '…' --user admin
    npm run passwd-reset -- --clear --keep-totp # keep 2FA instead of switching it off
    # inside Docker:
    docker compose exec blog npm run passwd-reset -- --clear

    --clear switches 2FA off by default, because a fresh account would otherwise still demand a code from the old authenticator. The existing credential file is copied to blog/data/admin.json.bak-<timestamp> (mode 0600) before anything changes.

  • Reset 2FA — delete the totp section (or blog/data/totp.json); the login goes back to password-only.

  • Health — GET /api/health for liveness, GET /api/status for content counts and process metrics; the /status/ page renders the same data.

  • Content updates — copying .md files in is enough; the cache is mtime-based, so no restart is needed.


Tech Stack

Layer Technology Version License
Runtime Node.js (≥ 22.18 required; Docker image uses 24) 22.18+ / 24 MIT
Language TypeScript, executed directly by Node (type stripping, no build step) 5.9.3 Apache-2.0
HTTP framework Express 5.2.1 MIT
Templates EJS (server-side rendering) 3.1.10 Apache-2.0
Frontend UI MDUI 2 (Material Design 3 Web Components), vendored locally 2.1.5 MIT
Markdown markdown-it (+ markdown-it-anchor for heading anchors) 14.3.2 MIT
Syntax highlighting highlight.js (server-side) 11.12.0 BSD-3-Clause
Math KaTeX (+ @vscode/markdown-it-katex) 0.16.47 MIT
Frontmatter gray-matter (js-yaml) with a tolerant fallback parser 4.0.3 MIT
Image processing sharp (512×512 WebP avatars) 0.33.5 Apache-2.0
Object storage @aws-sdk/client-s3 (S3 / MinIO / R2, incl. mutual TLS) 3.1142.0 Apache-2.0
SFTP ssh2-sftp-client 12.1.1 Apache-2.0
Uploads multer (memory storage, 10 MB limit) 2.4.0 MIT
Cookies cookie-parser 1.4.7 MIT
Config dotenv 16.6.1 BSD-2-Clause
Password hashing / TOTP / sessions Node.js built-in node:crypto (scrypt, HMAC-SHA256, AES-256-CBC for legacy verification) — MIT (Node.js)

Versions are the resolved versions from package.json / package-lock.json at the time of writing; npm outdated may suggest newer ones within the declared semver ranges.

Open Source Acknowledgments

This project is built on the work of many open-source projects. We thank their authors and contributors. Each entry lists the project, its license and where to find it.

Runtime and framework

Project License Link
Node.js MIT nodejs.org
TypeScript Apache-2.0 typescriptlang.org
Express MIT expressjs.com
EJS Apache-2.0 ejs.co

Front end

Project License Link
MDUI 2 (Material Design 3 Web Components) MIT mdui.org
KaTeX MIT katex.org
highlight.js BSD-3-Clause highlightjs.org
markdown-it MIT markdown-it.github.io

Storage, media and services

Project License Link
sharp Apache-2.0 sharp.pixelplumbing.com
AWS SDK for JavaScript v3 Apache-2.0 aws.amazon.com/sdk-for-javascript
ssh2-sftp-client Apache-2.0 github.com/theophilusx/ssh2-sftp-client
gray-matter MIT github.com/jonschlinkert/gray-matter
multer MIT github.com/expressjs/multer
cookie-parser MIT github.com/expressjs/cookie-parser
dotenv BSD-2-Clause github.com/motdotla/dotenv
markdown-it-anchor Unlicense github.com/valeriangalliat/markdown-it-anchor

Please refer to each project's own repository for the authoritative copyright notices and the full license text.

License

This project is licensed under the MIT License (full text in LICENSE). See the acknowledgements above for the respective licenses of the open-source components it uses.


Built with ❤️ on Node.js + TypeScript, MDUI 2, and more wonderful open-source tools.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages