Skip to content

Commit 61e911c

Browse files
committed
Add developer blog under /blog with admin editor and public RSS
Adds a full blog subsystem to the Downloads site so we can publish release notes and API write-ups under blog.bentobox.world (or download.bentobox.world/blog). Phase 1 + 2 in one drop: - BlogManager backed by data/Blog.sqlite with posts + post_revisions - markdown-it server-side rendering with highlight.js syntax highlighting - GitHub-style alert blockquotes ([!NOTE], [!TIP], …) become callouts - Admin tab with split-pane Markdown editor, live preview, autosave, image upload, tag input, datetime-local scheduling - Public list + permalink + tag archive + RSS 2.0 + sitemap.xml - /blog/p/:slug is server-rendered with OG / Twitter Card / JSON-LD - Per-minute cron promotes scheduled posts and pings Discord webhook - New canAuthorBlog flag on users (admins implied), pre-grantable via env.blog_author_discord_ids; cookie_domain support so a single Discord login covers download.* and blog.*
1 parent 5e9ee6a commit 61e911c

18 files changed

Lines changed: 2791 additions & 28 deletions

CLAUDE.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,26 @@ yarn site # Frontend-only dev rebuild + start server
4646
- Images live at `weblink/blueprints/images/<gameMode>/<name>.png` (recommended 800×450, with optional `<name>.thumb.png` at 400×225). The catalog auto-detects them at sync time when the overlay does not specify `image`. The Express handler at `src/index.ts` serves them under `/blueprints/images/...` with a path-traversal guard and PNG-only whitelist.
4747
- Endpoints: `GET /api/blueprints` (full catalog), `GET /api/blueprints/download?id=<gameMode>/<name>&type=blueprint|bundle` (single file), `GET /api/blueprints/zip?ids=[...]|gameMode=<gm>` (multi-select or whole-game-mode zip). IDs are validated against a strict regex and resolved paths are checked against the blueprints root before any read.
4848

49+
**Blog** (`src/api/blog.ts`, `src/api/models/blog.ts`, `src/web/components/blog/`, `src/web/components/admin/BlogTab.tsx`, `src/web/components/admin/BlogEditor.tsx`):
50+
- Backed by `data/Blog.sqlite` via Sequelize. Models: `posts` (slug-keyed permalinks, draft/published, markdown source + sanitized HTML cache, view counter) and `post_revisions` (one row per save, pruned daily after 90 days).
51+
- Markdown source is rendered server-side with `markdown-it` + `sanitize-html` on every save; the HTML is what's served to readers, so the public path doesn't ship a markdown engine. The admin editor preview uses `react-markdown` + `remark-gfm` + `remark-breaks` client-side.
52+
- Permissions: an author is anyone with `users.isAdmin = true` OR `users.canAuthorBlog = true`. Pre-grant via `env.blog_author_discord_ids` (mirrors `admin_discord_ids`). `requireBlogAuthor` middleware in `auth.ts` gates writes; the Admin page filters its tab list to only "Blog" for users who are blog authors but not full admins.
53+
- Endpoints: `GET /api/blog/posts?page&tag` (paginated public list, optional tag filter), `GET /api/blog/posts/:slug` (public, bumps view count), `GET /api/blog/tags` (aggregated tag counts across published posts), `GET /api/blog/feed.xml` (RSS 2.0), `GET /blog/sitemap.xml` (sitemap), `GET /blog/highlight.css` (highlight.js theme served from the bundled github-dark theme with our background override). Author endpoints under `/api/blog/admin/posts` (CRUD), `/api/blog/admin/posts/:id/publish` (with optional `{ at: <epoch_ms> }` body to schedule), `/api/blog/admin/posts/:id/unpublish`, `/api/blog/admin/images` for multipart uploads (5 MB cap, PNG/JPG/WebP/GIF only). All writes require `X-Csrf-Token` and `requireBlogAuthor`.
54+
- Permalinks at `/blog/p/:slug` are server-rendered with OG/Twitter Card meta + JSON-LD Article schema + canonical URL by `BlogManager.renderPostPage()`. Express checks for the path in `src/index.ts` before falling through to the SPA. On `blog.bentobox.world`, the bare `/p/:slug` and `/feed.xml` paths also work via a hostname check (`isBlogHost`).
55+
- On `draft → published` transition the manager fires a Discord webhook configured by `env.discord_blog_webhook_url` (full URL or `/api/webhooks/...` path). Failures are logged and ignored — they never block the publish.
56+
- Subdomain plumbing: in production set `env.cookie_domain = ".bentobox.world"` so Discord sessions cover both `download.*` and `blog.*`. Recommended nginx config: a single `server` block for `blog.bentobox.world` that rewrites `/(.*)` to `/blog/$1` before proxying to the same upstream `:8080` — keeps the React Router and SSR paths uniform.
57+
- Uploaded images live under `data/blog/images/YYYY/MM/<sha256-prefix>.<ext>` and are served by Express via the `/blog/images/...` static handler (path-traversal guarded, MIME whitelist).
58+
- Markdown extras: code fences are server-side syntax-highlighted via `highlight.js` (auto-detect for unfenced langs); GitHub-style alert blockquotes (`> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]`) are post-processed into `<div class="callout callout-X">` boxes with coloured labels.
59+
- Scheduled posts have `status='scheduled'` and a future `publishedAt`. A per-minute cron promotes them to `published` and fires the Discord webhook at promotion time, not at scheduling time.
60+
- Phase scope: Phase 1 = list + permalink + RSS + OG + Discord webhook + image uploads + draft/publish, single author. Phase 2 (current) adds tags + tag archive, code highlighting, GFM callouts, scheduled publishing, sitemap. Phase 3 will add multi-author grant UI, comments (Giscus), search, dev.to cross-post, git backup, analytics.
61+
4962
**Auth + submissions** (`src/api/auth.ts`, `src/api/submissions.ts`, `src/api/models/auth.ts`):
5063
- Discord OAuth (server-side authorization-code flow, `identify` scope only). Sessions stored in `data/Auth.sqlite` via Sequelize (`User`, `Session`, `Submission` tables). Session cookie is HttpOnly, `SameSite=Lax`, `Secure` in production, and carries an opaque server-generated session id. 30-day TTL, max 5 concurrent sessions per user, daily prune cron.
5164
- CSRF: per-session token returned by `GET /api/me`, required as `X-Csrf-Token` on every POST.
5265
- Rate limits: 30 OAuth callbacks/min/IP; 3 submissions/hour, 10/day per Discord user.
5366
- `POST /api/blueprints/submit` accepts a multipart upload (max 5 MB), JSON-parses, ajv-validates against `src/api/schemas/blueprint.schema.json` (vendored from `bentobox/`), runs `validateBlueprintSubmission` (rejects command-block materials and opaque `inventory` payloads), checks slug collision against the local clone, then opens a PR on `BentoBoxWorld/weblink` via Octokit using `env.weblink_github_token` — branch `submit/<discordId>/<slug>-<ts>` with two file commits (the `.blueprint` and the patched `catalog.json`).
5467
- Helmet config in `src/index.ts` is the canonical CSP — Discord OAuth navigation works without an explicit allowlist because helmet's CSP does not constrain top-level navigation.
55-
- `env.json` keys: `discord_client_id`, `discord_client_secret`, `discord_redirect_uri`, `weblink_github_token` (fine-grained PAT scoped to `BentoBoxWorld/weblink` with `contents: write` + `pull-requests: write`).
68+
- `env.json` keys: `discord_client_id`, `discord_client_secret`, `discord_redirect_uri`, `weblink_github_token` (fine-grained PAT scoped to `BentoBoxWorld/weblink` with `contents: write` + `pull-requests: write`), and the blog-related keys `blog_author_discord_ids`, `discord_blog_webhook_url`, `blog_base_url`, `cookie_domain`.
5669

5770
## Key Patterns
5871

env.example.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
"discord_redirect_uri": "https://download.bentobox.world/api/auth/discord/callback",
1414
"weblink_github_token": "github_pat_REPLACE_ME",
1515
"admin_discord_ids": ["REPLACE_WITH_DISCORD_USER_ID"],
16+
"blog_author_discord_ids": [],
17+
"discord_blog_webhook_url": "/api/webhooks/xxxxxxxxxxxxxxxxxxxxxxxxxxx",
18+
"blog_base_url": "https://blog.bentobox.world",
19+
"cookie_domain": ".bentobox.world",
1620
"admin_github": {
1721
"owner": "BentoBoxWorld",
1822
"repo": "Downloads",

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@fortawesome/free-solid-svg-icons": "^5.15.3",
2525
"@fortawesome/react-fontawesome": "^0.1.14",
2626
"@octokit/plugin-throttling": "^3.5.1",
27+
"highlight.js": "^11.10.0",
2728
"@tailwindcss/custom-forms": "^0.2.1",
2829
"@types/archiver": "^5.1.1",
2930
"@types/cookie-parser": "^1.4.10",
@@ -43,6 +44,7 @@
4344
"express": "^4.17.1",
4445
"helmet": "^4.6.0",
4546
"jenkins": "^0.28.1",
47+
"markdown-it": "^13.0.2",
4648
"mime-types": "^2.1.31",
4749
"multer": "^2.1.1",
4850
"node-cron": "^3.0.0",
@@ -54,7 +56,9 @@
5456
"react-markdown": "^6.0.3",
5557
"react-router-dom": "^5.2.0",
5658
"remark-breaks": "^2.0.2",
59+
"remark-gfm": "^1.0.0",
5760
"sequelize": "^6.6.5",
61+
"slugify": "^1.6.6",
5862
"source-map-loader": "^3.0.0",
5963
"sqlite3": "^6.0.1",
6064
"styled-components": "^5.3.0",
@@ -70,6 +74,7 @@
7074
"@babel/preset-react": "^7.13.13",
7175
"@babel/preset-typescript": "^7.13.0",
7276
"@babel/runtime": "^7.13.16",
77+
"@types/markdown-it": "^13.0.7",
7378
"@types/multer": "^2.1.0",
7479
"@types/react": "^17.0.15",
7580
"@types/react-dom": "^17.0.3",

src/api/api.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from './blueprintCatalog';
2525
import { AdminGithubConfig, AdminManager } from './admin';
2626
import { AuthConfig, AuthManager } from './auth';
27+
import { BlogConfig, BlogManager } from './blog';
2728
import { ConfigStore } from './configStore';
2829
import { SubmissionManager } from './submissions';
2930

@@ -62,6 +63,14 @@ export default class ApiManager {
6263
admin: AdminManager;
6364
submissions: SubmissionManager = new SubmissionManager(this.auth, this.weblink, this.loadSubmissionsConfig());
6465

66+
blogSequelize = new Sequelize('blog', 'user', 'password', {
67+
host: 'localhost',
68+
dialect: 'sqlite',
69+
logging: false,
70+
storage: './../data/Blog.sqlite',
71+
});
72+
blog: BlogManager = new BlogManager(this.auth, this.blogSequelize, this.loadBlogConfig());
73+
6574
jarSequelize = new Sequelize('database', 'user', 'password', {
6675
host: 'localhost',
6776
dialect: 'sqlite',
@@ -281,18 +290,42 @@ export default class ApiManager {
281290
const adminDiscordIds = Array.isArray(env.admin_discord_ids)
282291
? (env.admin_discord_ids as unknown[]).filter((x): x is string => typeof x === 'string')
283292
: [];
293+
const blogAuthorDiscordIds = Array.isArray(env.blog_author_discord_ids)
294+
? (env.blog_author_discord_ids as unknown[]).filter(
295+
(x): x is string => typeof x === 'string',
296+
)
297+
: [];
284298
return {
285299
clientId: env.discord_client_id,
286300
clientSecret: env.discord_client_secret,
287301
redirectUri: env.discord_redirect_uri,
288302
cookieSecure: inProd,
289303
adminDiscordIds,
304+
blogAuthorDiscordIds,
305+
cookieDomain: typeof env.cookie_domain === 'string' ? env.cookie_domain : undefined,
290306
};
291307
}
292308
} catch (e) {}
293309
return null;
294310
}
295311

312+
private loadBlogConfig(): BlogConfig {
313+
let baseUrl: string | undefined;
314+
let discordWebhookPath: string | undefined;
315+
try {
316+
const env = require('./../../env.json');
317+
if (typeof env?.blog_base_url === 'string') baseUrl = env.blog_base_url;
318+
if (typeof env?.discord_blog_webhook_url === 'string') {
319+
discordWebhookPath = env.discord_blog_webhook_url;
320+
}
321+
} catch (e) {}
322+
return {
323+
baseUrl,
324+
discordWebhookPath,
325+
imageDir: './../data/blog/images',
326+
};
327+
}
328+
296329
async refreshBlueprints() {
297330
try {
298331
await this.weblink.sync();

src/api/auth.ts

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ export interface AuthConfig {
1919
redirectUri: string;
2020
cookieSecure: boolean; // true in prod, false on localhost dev
2121
adminDiscordIds?: string[];
22+
blogAuthorDiscordIds?: string[];
23+
/** When set, session cookies are issued with Domain=<value> so a single
24+
* Discord login covers download.* and blog.* under the same eTLD+1.
25+
* Use ".bentobox.world" in production; leave undefined for localhost. */
26+
cookieDomain?: string;
2227
}
2328

2429
const SESSION_COOKIE = 'bb_session';
@@ -63,16 +68,18 @@ export class AuthManager {
6368

6469
private async bootstrap(sequelize: Sequelize): Promise<void> {
6570
await this.users.sync();
66-
// Older Auth.sqlite databases predate the isAdmin column. Add it
71+
// Older Auth.sqlite databases predate these columns. Add them
6772
// idempotently — addColumn throws if it already exists, which we ignore.
68-
try {
69-
await sequelize.getQueryInterface().addColumn('users', 'isAdmin', {
70-
type: BOOLEAN,
71-
defaultValue: false,
72-
allowNull: false,
73-
});
74-
} catch (e) {
75-
// Column already present.
73+
for (const col of ['isAdmin', 'canAuthorBlog']) {
74+
try {
75+
await sequelize.getQueryInterface().addColumn('users', col, {
76+
type: BOOLEAN,
77+
defaultValue: false,
78+
allowNull: false,
79+
});
80+
} catch (e) {
81+
// Column already present.
82+
}
7683
}
7784
await this.sessions.sync();
7885
await this.submissions.sync();
@@ -81,6 +88,10 @@ export class AuthManager {
8188
for (const id of adminIds) {
8289
await this.setAdmin(id, true);
8390
}
91+
const blogIds = this.config?.blogAuthorDiscordIds ?? [];
92+
for (const id of blogIds) {
93+
await this.setBlogAuthor(id, true);
94+
}
8495
}
8596

8697
/** Idempotent. Granting creates a stub user row when the Discord ID has
@@ -103,9 +114,55 @@ export class AuthManager {
103114
lastLoginAt: 0,
104115
acceptedTermsVersion: null,
105116
isAdmin: true,
117+
canAuthorBlog: false,
106118
});
107119
}
108120

121+
/** Idempotent. Same pre-grant behavior as setAdmin. */
122+
async setBlogAuthor(discordId: string, canAuthorBlog: boolean): Promise<void> {
123+
const existing = await this.users.findByPk(discordId);
124+
if (existing) {
125+
if (existing.canAuthorBlog !== canAuthorBlog) await existing.update({ canAuthorBlog });
126+
return;
127+
}
128+
if (!canAuthorBlog) return;
129+
const now = Date.now();
130+
await this.users.create({
131+
id: discordId,
132+
username: '',
133+
globalName: null,
134+
avatarHash: null,
135+
createdAt: now,
136+
lastLoginAt: 0,
137+
acceptedTermsVersion: null,
138+
isAdmin: false,
139+
canAuthorBlog: true,
140+
});
141+
}
142+
143+
async listBlogAuthors(): Promise<
144+
Array<{
145+
id: string;
146+
username: string;
147+
globalName: string | null;
148+
avatarUrl: string | null;
149+
isAdmin: boolean;
150+
}>
151+
> {
152+
const rows = await this.users.findAll({
153+
where: { [Op.or]: [{ isAdmin: true }, { canAuthorBlog: true }] },
154+
});
155+
return rows.map((u) => ({
156+
id: u.id,
157+
username: u.username,
158+
globalName: u.globalName,
159+
avatarUrl: u.avatarHash
160+
? `https://cdn.discordapp.com/avatars/${u.id}/${u.avatarHash}.png?size=64`
161+
: null,
162+
isAdmin: !!u.isAdmin,
163+
}));
164+
}
165+
109166
async listAdmins(): Promise<
110167
Array<{
111168
id: string;
@@ -141,12 +198,14 @@ export class AuthManager {
141198
return;
142199
}
143200
const state = randomToken();
201+
const cookieDomain = this.config.cookieDomain;
144202
res.cookie(STATE_COOKIE, state, {
145203
httpOnly: true,
146204
secure: this.config.cookieSecure,
147205
sameSite: 'lax',
148206
maxAge: STATE_TTL_MS,
149207
path: '/',
208+
...(cookieDomain ? { domain: cookieDomain } : {}),
150209
});
151210
const returnTo = safeReturnPath(req.query.return);
152211
if (returnTo) {
@@ -156,10 +215,11 @@ export class AuthManager {
156215
sameSite: 'lax',
157216
maxAge: STATE_TTL_MS,
158217
path: '/',
218+
...(cookieDomain ? { domain: cookieDomain } : {}),
159219
});
160220
} else {
161221
// Stale cookie from a prior aborted flow could otherwise win.
162-
res.clearCookie(RETURN_COOKIE, { path: '/' });
222+
res.clearCookie(RETURN_COOKIE, { path: '/', ...(cookieDomain ? { domain: cookieDomain } : {}) });
163223
}
164224
const params = new URLSearchParams({
165225
client_id: this.config.clientId,
@@ -185,9 +245,11 @@ export class AuthManager {
185245
res.status(400).send('OAuth state mismatch. Please try again.');
186246
return;
187247
}
188-
res.clearCookie(STATE_COOKIE, { path: '/' });
248+
const cookieDomain = this.config.cookieDomain;
249+
const domainOpt = cookieDomain ? { domain: cookieDomain } : {};
250+
res.clearCookie(STATE_COOKIE, { path: '/', ...domainOpt });
189251
const returnTo = safeReturnPath(cookies?.[RETURN_COOKIE]) ?? DEFAULT_POST_LOGIN_PATH;
190-
res.clearCookie(RETURN_COOKIE, { path: '/' });
252+
res.clearCookie(RETURN_COOKIE, { path: '/', ...domainOpt });
191253

192254
let identity: { id: string; username: string; global_name: string | null; avatar: string | null };
193255
try {
@@ -234,6 +296,7 @@ export class AuthManager {
234296
lastLoginAt: now,
235297
acceptedTermsVersion: null,
236298
isAdmin: false,
299+
canAuthorBlog: false,
237300
});
238301
}
239302

@@ -263,6 +326,7 @@ export class AuthManager {
263326
sameSite: 'lax',
264327
maxAge: SESSION_TTL_MS,
265328
path: '/',
329+
...domainOpt,
266330
});
267331
res.redirect(returnTo);
268332
};
@@ -272,7 +336,8 @@ export class AuthManager {
272336
if (session) {
273337
await session.destroy();
274338
}
275-
res.clearCookie(SESSION_COOKIE, { path: '/' });
339+
const cookieDomain = this.config?.cookieDomain;
340+
res.clearCookie(SESSION_COOKIE, { path: '/', ...(cookieDomain ? { domain: cookieDomain } : {}) });
276341
res.json({ ok: true });
277342
};
278343

@@ -298,6 +363,7 @@ export class AuthManager {
298363
csrfToken: session.csrfToken,
299364
currentTermsVersion: TERMS_VERSION,
300365
isAdmin: !!user.isAdmin,
366+
canAuthorBlog: !!user.isAdmin || !!user.canAuthorBlog,
301367
});
302368
};
303369

@@ -310,7 +376,8 @@ export class AuthManager {
310376
const userId = session.userId;
311377
await this.sessions.destroy({ where: { userId } });
312378
await this.users.destroy({ where: { id: userId } });
313-
res.clearCookie(SESSION_COOKIE, { path: '/' });
379+
const cookieDomain = this.config?.cookieDomain;
380+
res.clearCookie(SESSION_COOKIE, { path: '/', ...(cookieDomain ? { domain: cookieDomain } : {}) });
314381
res.json({ ok: true });
315382
};
316383

@@ -371,6 +438,21 @@ export class AuthManager {
371438
next();
372439
};
373440

441+
/** Admins always count as blog authors. */
442+
requireBlogAuthor: RequestHandler = async (req, res, next) => {
443+
const session = (req as AuthedRequest).session;
444+
if (!session) {
445+
res.status(401).json({ error: 'unauthenticated' });
446+
return;
447+
}
448+
const user = await this.users.findByPk(session.userId);
449+
if (!user || (!user.isAdmin && !user.canAuthorBlog)) {
450+
res.status(403).json({ error: 'forbidden' });
451+
return;
452+
}
453+
next();
454+
};
455+
374456
// -------- Rate limiting --------
375457
//
376458
// Sliding-window in-memory limiter keyed by IP for the OAuth callback,

0 commit comments

Comments
 (0)