453 lines
20 KiB
Markdown
453 lines
20 KiB
Markdown
# 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.
|
||
|
||
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.
|