Files
buzz-sheet/README.md
T
gunshiz 20c52fac04
CI / Verify (push) Successful in 1m41s
CI / Build immutable images and deploy (push) Successful in 2m30s
feat(comments) : filter abusive language and prevent spam
2026-10-08 04:45:45 +07:00

463 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Buzz Guide
A Thai-first Genshin Impact guide CMS for publishing character builds, teams,
rotations, and comparisons from structured data.
**Live site:** [guide.sudloh.com](https://guide.sudloh.com)
Buzz Guide is an independent community project and is not affiliated with or
endorsed by HoYoverse. The repository and deployment resources retain the
`buzz-sheet` name.
## Features
- **Public guides:** a searchable character directory and responsive guide pages.
- **Visual editing:** structured editors for overviews, weapons, artifacts,
constellations, teams, and custom sections, with autosave and conflict recovery.
- **Accounts:** Guide email/password sign-in, email verification, local profiles,
and rolling sessions. Existing users who signed up through Sudloh can set a
Guide password from the password-reset page.
- **Media:** S3-compatible uploads and publication-aware delivery for staged files.
- **Catalog updates:** Discord-triggered synchronization with Lunaris.
- **Commissions:** PromptPay checkout, Slip2Go verification, ticket attachments,
and opt-in browser push notifications.
- **Shared state:** PostgreSQL transactions, a retryable outbox, Redis caching,
and Server-Sent Events across application replicas.
The five reference workbooks are included only as sanitized structural and
formula fixtures. Their guide text and media are not imported or published.
## Contents
- [Local development](#local-development)
- [Commands](#commands)
- [Routes](#routes)
- [How updates and uploads work](#how-updates-and-uploads-work)
- [Verification](#verification)
- [Containers](#containers)
- [Kubernetes](#kubernetes)
- [Acknowledgements](#acknowledgements)
## Local development
### Requirements
- Bun **1.3.14**
- PostgreSQL
- Redis
- S3-compatible object storage
### Setup
```bash
git clone https://git.astrxl.dev/astral/buzz-sheet.git
cd buzz-sheet
bun install --frozen-lockfile
cp .env.example .env
```
Fill in `.env` using the placeholders in `.env.example`. Keep credentials out of
Git; `.env` is ignored by the repository.
| Setting | Purpose |
| --- | --- |
| `BETTER_AUTH_URL` | Must exactly match the application's origin. |
| `BASE_URL` | Public origin used for SEO metadata, the sitemap, and robots.txt. |
| Database, Redis, and S3 settings | Connection details for the required services. |
Apply the committed database migrations, then start the development server:
```bash
bun run db:migrate
bun run dev
```
Visitors can sign in at `/auth/login` or register at `/auth/register`.
For background processing, run the
outbox worker in a separate terminal. Run the Discord worker when using catalog
updates; its configuration is described below.
```bash
bun run worker:outbox
```
```bash
bun run worker:discord
```
Email, commission payments, and push notifications need their corresponding
service credentials. See [External prerequisites](#external-prerequisites) for
the production configuration details.
## Commands
| Command | Purpose |
| --- | --- |
| `bun run dev` | Start the Next.js development server |
| `bun run test` | Run the Vitest unit and integration suite |
| `bun run test:coverage` | Produce V8 coverage output |
| `bun run typecheck` | Run TypeScript without emitting files |
| `bun run lint` | Run ESLint |
| `bun run security:audit` | Check dependency advisories and time-limited exceptions |
| `bun run build` | Compile the production application |
| `bun run db:generate` | Generate a Drizzle migration from schema changes |
| `bun run db:migrate` | Apply committed Drizzle migrations |
| `bun run import:guides` | Preview the one-time Google Sheets guide migration (`-- --apply` to write) |
| `bun run worker:discord` | Listen for Lunaris update announcements |
| `bun run worker:outbox` | Process cache and event outbox records |
## Routes
| Route | Purpose |
| --- | --- |
| `/` | Searchable public character directory |
| `/[character]` | Redirect to the first visible page |
| `/[character]/[page]` | Render a public guide page |
| `/auth/login` | Public account sign-in |
| `/auth/register` | Public account registration |
| `/admin` | Character and page overview |
| `/admin/[character]/[page]` | Visual page editor |
| `/admin/create` | Create a structured guide from the synced character catalog |
| `/media/[id]` | Same-origin media response with publication checks |
| `/api/healthz` | PostgreSQL, Redis, Slip2Go, Discord bot, and Lunaris status |
The comprehensive check returns 503 if any dependency fails. Slip2Go uses a HEAD request to
check reachability without submitting a payment slip; it cannot validate the API secret.
The Discord check requires a heartbeat from the connected catalog worker within 45 seconds.
A page is public only when both its page and character are visible. A visible
character without a visible page returns 404.
## How updates and uploads work
### Editing and live updates
Edits carry an `expectedVersion`. Accepted changes save guide content, media
reference counts, and outbox records in a single PostgreSQL transaction.
The outbox worker invalidates the affected Redis cache tags before publishing
an event containing only `{ type, id, version }`. Clients then fetch the current
state. SSE carries change notifications, never guide content; PostgreSQL remains
the source of truth.
Public guides use a shared Redis-backed Next.js cache. Admin routes stay dynamic.
SSE streams send a heartbeat every 90 seconds and close after 30 minutes so
clients reconnect. Response headers disable Traefik buffering.
### Discord catalog synchronization
The catalog worker watches the private channel set by `DISCORD_CHANNEL_ID`.
Every new message, including one sent by you, triggers a check and synchronization
with the latest version reported by Lunaris.
- Startup connects the listener without replaying history or starting a sync.
- Results go to `DISCORD_LOG_CHANNEL_ID`; start notifications link to the
triggering message.
- Send `!lunaris stop` in the watched channel to cancel the current fetch and
clear queued fetches. A later message starts another check.
- Enable the Discord Message Content intent and grant the bot View Channel and
Send Messages in the configured channels.
### Guide comments
Comment tables live in the dedicated PostgreSQL `comments` schema: `comment`,
`thread`, `revision`, `attachment`, `revision_attachment`, `reaction`,
`push_subscription`, and `read_state`.
Character guides have a Comment section at `/{slug}/comment`. Stygian comments
appear at the bottom of `/stygian?schedule={id}` and remain accessible at
`/stygian/comment?schedule={id}`; both share the selected schedule's discussion. Readers can
browse anonymously; posting, replies, likes, and dislikes require an account.
Replies expand inline with connector lines; replies to replies identify their recipient.
The Thai comment UI supports image paste (Ctrl+V) with previews and loading skeletons.
Comment boards and the admin inbox receive Redis-backed SSE change events;
reads after each event recheck authorization. Reconnecting refreshes missed updates.
One site-entry prompt enables browser notifications for comments, Commission,
and future notification features. “ไม่ต้องถามอีก” remembers dismissal in this
browser; the header bell settings can reopen the prompt. Permission granted before
sign-in is connected to the account after sign-in. New replies notify
only the direct parent author, excluding self-replies. Subscribed, verified admins
also receive new top-level comments from other authors, with a link that opens the
comment in an admin conversation dialog. Admins can enable this from the inbox
using “แจ้งเตือนความคิดเห็นใหม่”. Reply notification clicks open the
root thread, load the relevant reply page, and highlight/focus the exact reply.
This uses the existing `WEB_PUSH_PUBLIC_KEY`, `WEB_PUSH_PRIVATE_KEY`, and
`WEB_PUSH_SUBJECT` configuration, with subscriptions in `comments.push_subscription`.
The header bell lists comments and Commission notifications from `public.notification`,
updates through one user-scoped SSE stream, and caps its red unread badge at `9+`.
Clicking an item marks it read; “อ่านทั้งหมด” marks the account's history read.
Read state persists across visits, admin notifications recheck current permissions,
and source deletion removes its notifications through cascading foreign keys.
Likes, dislikes, and admin hearts update optimistically and save in the background;
a failed save rolls the reaction back and shows an error.
Each comment or reply supports 4,000 characters and up to five JPEG, PNG, or
WebP images, each no larger than 10 MiB. Image bytes are uploaded to the
S3 bucket through Bun’s `S3Client`; PostgreSQL stores only object keys, metadata,
and revision references. Browsers load images directly from `S3_PUBLIC_URL`. Videos are unsupported. Authors can edit
or delete their comments, and the “แก้ไขแล้ว” link exposes previous text and images.
Deletion permanently removes the comment and every descendant reply, including their
revision history, attachment metadata, and reactions. Their S3 images are also deleted.
`/admin/comments` provides the cross-guide inbox, guide/schedule filtering,
visible/hidden and unanswered filters, replies, reactions, admin hearts, and
hide/restore moderation. Conversation buttons appear only on comments with replies;
the dialog shows a thread skeleton while loading. Admin-only unread badges update
via SSE, cap at `99+`, and link to the guide inbox. Opening the inbox marks its
selected guide (or all guides) read for that admin. Hiding a root also hides its replies. Moderation actions
are recorded in the activity log. Comment reads and legacy attachment redirects
are not cached; guide content caches are independent of discussions.
Comment writes (including replies and edits) are limited per account across all
guides to one every 5 seconds, 5 per minute, and 30 per hour using shared Redis.
New text comments cannot repeat the same normalized text within 5 minutes;
failed uploads or saves release that duplicate reservation. Edits and image-only
comments still use the write limits. Before uploading images or saving, the server
rejects a focused Thai/English abusive-term list, common English spelling
obfuscations, more than 3 links, 20 identical consecutive characters, and a word
or short phrase repeated 8 times. The rules live in `lib/comments/moderation.ts`;
they are heuristic text filters, not image moderation or an automated review queue.
The comment migration must run before deploying these pages. For PostgreSQL
integration verification, point `DATABASE_INTEGRATION_URL` at a migrated test or
development database and run `bun run test tests/comments.integration.test.ts`.
The suite creates isolated fixtures and removes them afterward; image storage is
mocked while decoding and database operations are exercised.
### Media visibility
| Upload path | Visibility |
| --- | --- |
| Presigned staged upload | Private in S3. `/media/[id]` allows public access only while a published guide references the object; otherwise administrator authorization is required. |
| Direct guide image upload | Public CDN object, including uploads made for drafts. |
| Comment attachment | Stored in Bun S3 and loaded directly from `S3_PUBLIC_URL`. Hiding removes image links from public comment responses; existing URLs remain accessible until the S3 object is deleted. `/api/comments/images/[id]` only redirects old links after a visibility check. |
| Commission attachment | Served from `S3_PUBLIC_URL`; anyone with the URL can view it. |
Keep sensitive material out of the direct upload path and commission attachments.
## Verification
Run the complete host-safe verification set with:
```bash
bun install --frozen-lockfile
bun run security:audit
bun run test
bun run typecheck
bun run lint
kubectl kustomize k8s/ >/dev/null
```
The formula suite validates the exact sanitized corpus of 379 extracted formula
cells plus arithmetic edge cases. An optional Redis integration test uses
independent connections for two conceptual replicas:
```bash
REDIS_INTEGRATION_URL=redis://127.0.0.1:6379 \
bun run test -- tests/redis-replication.integration.test.ts
```
It uses unique key and channel prefixes and removes its cache state afterward.
Without `REDIS_INTEGRATION_URL`, that external-service test is skipped.
## Containers
The application image compiles Next.js in a Node builder stage, then runs the
standalone server and bundled workers on Bun as the unprivileged numeric
UID/GID `1000:1000`. The migration image runs the committed Drizzle migrations
with the same identity.
```bash
docker build \
--target app \
--build-arg BASE_URL=https://guide.sudloh.com \
--build-arg NEXT_DEPLOYMENT_ID=<immutable-revision> \
--build-arg VCS_REF=<immutable-revision> \
--tag buzz-sheet:<immutable-revision> \
.
docker build \
--target migration \
--build-arg VCS_REF=<immutable-revision> \
--tag buzz-sheet:migrate-<immutable-revision> \
.
```
The runtime images do not contain local `.env` files.
`BASE_URL` is required during the Next.js build and at runtime. CI supplies the
build argument, and the Kubernetes ConfigMap supplies the runtime value. Keep
both values aligned when changing the public site origin.
## Kubernetes
Kustomize resources live in `k8s/` and define:
- Namespace `buzz-sheet`.
- Two rolling web replicas, one outbox worker, and one Discord catalog worker.
- ClusterIP port 3000 and Traefik ingress for `guide.sudloh.com`.
- Cloudflare edge TLS with the cluster's standard HTTP Traefik origin route.
- Startup, liveness, and dependency-aware readiness probes.
- Web requests of 500m CPU/1 GiB and limits of 1 CPU/2 GiB.
- HPA from 2 to 6 web replicas at 70% CPU or 75% memory and a PDB with one available.
- Non-root, read-only containers with dropped capabilities and seccomp.
- Ingress NetworkPolicy allowing the web pods only from Traefik in `kube-system`.
- ARM64 scheduling constraints matching the production hosts and images.
- A versioned migration Job and namespace-scoped CI deployer permissions.
PostgreSQL, Redis, and S3 are external services. The manifests deliberately do
not create in-cluster data stores or credentials.
### External prerequisites
#### Application secrets
Before the first rollout, a cluster administrator must provision a
`buzz-sheet-env` Secret in the `buzz-sheet` namespace containing:
```text
DATABASE_URL
BETTER_AUTH_SECRET
RESEND_API_KEY
REDIS_URL
S3_ENDPOINT
S3_PUBLIC_URL
S3_BUCKET
S3_ACCESS_KEY_ID
S3_SECRET_ACCESS_KEY
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
TURNSTILE_SITE_KEY
TURNSTILE_SECRET_KEY
COMMISSION_PROMPTPAY_TYPE
COMMISSION_PROMPTPAY_VALUE
COMMISSION_RECEIVER_ACCOUNT_NUMBER
SLIP2GO_VERIFY_URL
SLIP2GO_API_SECRET
WEB_PUSH_PUBLIC_KEY
WEB_PUSH_PRIVATE_KEY
WEB_PUSH_SUBJECT
DISCORD_BOT_TOKEN
DISCORD_CHANNEL_ID
```
`NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` must be generated once and remain stable
across replicas and rolling deployments. Do not place secret values in the
ConfigMap or commit them to this repository.
#### Email verification
Before deploying required email verification, add `sudloh.com` to Resend and
publish the DNS records shown in its domain setup. Verify the domain, create a
sending-only API key restricted to it, and set `RESEND_API_KEY` in the external
Secret. Account verification and password reset emails are sent from
`Buzz Guide <[email protected]>`; this address does not need an inbox. Existing
sessions continue until they expire or the user signs out, after which an
unverified address must be verified before signing in again.
#### Commission payments and attachments
The commission page accepts PromptPay mobile, national ID, or e-wallet identifiers.
For `COMMISSION_PROMPTPAY_TYPE=mobile`, use a real 10-digit Thai mobile number
registered with PromptPay for receiving transfers in `COMMISSION_PROMPTPAY_VALUE`.
For `nationalId` use the registered 13-digit ID; for `ewallet` use its 15-digit ID.
An ordinary bank account number cannot be used as the mobile identifier.
Set `COMMISSION_RECEIVER_ACCOUNT_NUMBER` to the recipient value in the format
Slip2Go expects for its receiver check. Set `SLIP2GO_VERIFY_URL` to the full
image-verification endpoint from your Slip2Go API Connect account. Set
`SLIP2GO_API_SECRET` to the raw secret; the server adds the `Bearer` authorization
prefix when calling Slip2Go. Payment slips and ticket images use the configured
S3 bucket and are served from `S3_PUBLIC_URL`. Anyone with an attachment URL can
view it. Each checkout accepts payment for one hour after creation. The outbox
worker removes expired unpaid checkouts; paid tickets remain available. Apply
the commission database migration before enabling checkout.
#### Browser push notifications
Commission message notifications require a stable VAPID key pair and subject
(`bunx web-push generate-vapid-keys --json`). Set the three `WEB_PUSH_*` values
in the web deployment secret and apply the push-subscription migration before
enabling notifications. Users opt in from a prompt when entering the commission area. Browser push can
arrive after tabs close; on iPhone and iPad, the site must be added to the Home
Screen before the browser offers push permission.
#### Ingress and client IP handling
Traefik must overwrite `X-Real-Ip` for every request, and the web pods must be
reachable only through Traefik before enabling the configured client IP based
rate limits. The NetworkPolicy assumes Traefik runs in `kube-system` with the
`app.kubernetes.io/name=traefik` pod label; verify those labels in the target
cluster before applying it.
#### Deployment notifications
Every page also subscribes to `/api/active`. When a serving pod reports a newer
deployment ID than the browser's current build, a persistent Thai notification
offers a full reload into the new release.
#### Initial bootstrap
The base manifests contain deliberate `replace-me` image and deployment values.
For initial bootstrap, the platform operator applies `k8s/base` through an
environment overlay that supplies an existing immutable image revision. The
aggregate `k8s/` kustomization includes the migration resource for rendering and
validation; normal releases create their own revision-named migration Job.
Use this command for local manifest validation only:
```bash
kubectl kustomize k8s/ >/dev/null
```
### Gitea Actions release
`.gitea/workflows/ci.yml` verifies pushes to `main` and scheduled runs, including a
dependency audit and a Git history secret scan. A time-limited advisory
exception is recorded in `security/audit-exceptions.json`. After the
repository variable `DEPLOY_ENABLED` is explicitly set to `true`, a successful
push to `main` builds and scans ARM64 images at:
```text
registry.neko-piranha.ts.net/astral/buzz-sheet:<git-sha>
registry.neko-piranha.ts.net/astral/buzz-sheet:migrate-<git-sha>
```
Configure the following Gitea secret before enabling deployment:
| Secret | Purpose |
| --- | --- |
| `KUBE_CONFIG_B64` | Base64 kubeconfig for the `ci-deployer` identity |
The kubeconfig must contain a certificate-valid API server address and its CA.
The workflow rejects `insecure-skip-tls-verify` and does not rewrite the
cluster server address.
The deploy job creates a revision-named migration Job, waits for completion,
updates `NEXT_DEPLOYMENT_ID`, and only then rolls out the immutable application
image to the web and both worker deployments. Schema changes must follow an
expand/contract sequence so the previous application revision stays compatible
during a rolling update.
## Acknowledgements
This project would not be possible without the open-source software and
community services it builds on:
- [Lunaris](https://lunaris.moe/) - character, talent, material, and game data
used by catalog synchronization and guide tools. Buzz Guide links back to
Lunaris for material references where appropriate.
- [Next.js](https://nextjs.org/) - application framework.
- [React](https://react.dev/) - user interface library.
- [Bun](https://bun.sh/) - JavaScript runtime and package manager.
- [PostgreSQL](https://www.postgresql.org/) - primary database.
- [Redis](https://redis.io/) - cache, event transport, and presence state.
- [Discord.js](https://discord.js.org/) - Discord catalog update worker.
- [Drizzle ORM](https://orm.drizzle.team/) - database schema and migrations.
- [shadcn/ui](https://ui.shadcn.com/), [Tailwind CSS](https://tailwindcss.com/),
[Lucide](https://lucide.dev/), and [dnd-kit](https://dndkit.com/) - interface
components, styling, icons, and drag-and-drop interactions.
Special thanks are also available on the site’s [Special Thanks page](https://guide.sudloh.com/thanks).
Genshin Impact and its characters, names, and assets are property of their
respective rights holders. Please review the applicable terms and licenses for
each third-party dependency before redistributing or deploying this project.