From 59a9cc1c190986ace1ab8441d890fb12993f93d1 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 16 Aug 2026 00:48:15 +0700 Subject: [PATCH] docs: add authenticated sharing plan --- PLAN.md | 334 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..e0e90bb --- /dev/null +++ b/PLAN.md @@ -0,0 +1,334 @@ +# Authenticated Text Sharing Plan + +## Overview + +Create a dedicated text-sharing feature with: + +- A simple `/share` upload page inspired by mclo.gs. +- Publicly readable pages at `/share/[id]`. +- Discord login required before web uploads or comments. +- A private bot endpoint for forwarding Discord `.txt` attachments. +- PostgreSQL tables `share.text` and `share.comment`. +- Duplicate-safe synchronization with existing NextAuth accounts. + +## System Diagram + +### Web upload + +```mermaid +flowchart TD + A["Visitor opens /share"] --> B{"Discord session?"} + B -- No --> C["LoginDialog"] + C --> D["Discord OAuth"] + D --> E["Return to /share with session"] + E --> F + B -- Yes --> F["Upload form
Paste or drop .txt
Description and optional image"] + F --> G["Session-protected web handler"] + G --> H["Validate text, description, and image"] + H --> I["Check 10 shares/hour"] + I --> J["Resolve Discord account"] + J --> K["Store image and share atomically"] + K --> L["Redirect to /share/a1b2c3d4"] +``` + +### Bot upload + +```mermaid +flowchart TD + A["Discord member sends a .txt file"] --> B["External Discord bot"] + B -->|"file + senderDiscordId
Bearer SHARE_BOT_SECRET"| C["POST /api/share"] + C --> D["Verify private bot secret"] + D --> E["Validate .txt and optional image"] + E --> F["Check bot and sender rate limits"] + F --> G["Verify sender is a guild member"] + G --> H["Fetch global name and avatar from Discord"] + H --> I["Ensure one Discord account"] + I --> J{"Discord account link exists?"} + J -- Yes --> K["Update existing account.user"] + J -- No --> L["Create one account.user
and Discord account.account link"] + K --> M["Create share.text atomically"] + L --> M + M --> N["Return /share/id"] + N --> O["Bot sends link to Discord"] +``` + +### Database relationships + +```mermaid +erDiagram + ACCOUNT_USER ||--o{ ACCOUNT_ACCOUNT : "has OAuth links" + ACCOUNT_USER o|--o{ SHARE_TEXT : "authors" + ACCOUNT_USER o|--o{ SHARE_COMMENT : "writes" + SHARE_TEXT ||--o{ SHARE_COMMENT : "contains" + PUBLIC_CDN o|--o| SHARE_TEXT : "optional image" + + ACCOUNT_USER { + text id PK + text discord_id UK + text name + text image + } + + ACCOUNT_ACCOUNT { + text userId FK + text provider PK + text providerAccountId PK + } + + SHARE_TEXT { + varchar id PK "8 lowercase alphanumeric characters" + text content + text description "optional" + text image_cdn_id FK "optional" + text source "web or bot" + text author_user_id FK "optional" + text author_discord_id + text author_name + text author_avatar_url "optional" + timestamp created_at + } + + SHARE_COMMENT { + text id PK "UUIDv7" + varchar text_id FK + text body + text author_user_id FK "optional" + text author_discord_id + text author_name + text author_avatar_url "optional" + timestamp created_at + } + + PUBLIC_CDN { + text id PK + bytea data + text type + integer size + } +``` + +### Public shared page + +```mermaid +flowchart TB + subgraph PAGE["/share/id"] + direction TB + A["Author avatar · Global display name · Bangkok time"] + B["Optional description"] + C["Optional image"] + D["Main shared text
Whitespace-preserving monospace"] + E["Copy text
Copies main text only"] + F["Copy link
Copies page URL"] + G["Comments
Oldest or newest · 50 per page"] + H["Public to read
Discord login required to post"] + + A --> B --> C --> D + D --> E + D --> F + E --> G + F --> G + G --> H + end +``` + +## Database and Identity + +- Add `share` to Drizzle's `schemaFilter`. +- Define a dedicated `pgSchema("share")`. +- Create `share.text` with: + - Random eight-character lowercase alphanumeric primary key. + - Exact main text content. + - Optional plain-text description. + - Optional CDN image reference. + - Source value of `web` or `bot`. + - Nullable `account.user` reference. + - Discord ID, display-name, and avatar snapshots. + - Creation timestamp. +- Create `share.comment` with: + - UUIDv7 primary key. + - Parent `share.text` reference with cascade deletion. + - Comment body. + - Nullable `account.user` reference. + - Discord identity snapshots. + - Creation timestamp. + - An index supporting stable sorting and pagination. +- Repair the missing Drizzle snapshot for migration `0017` before generating the share migration. + +## Duplicate-Safe Discord Accounts + +Bot uploads must not create multiple users for the same Discord account. + +- Backfill `account.user.discord_id` from existing Discord account links. +- Add a unique index for non-null Discord IDs. +- Implement a shared transactional `ensureDiscordUser` operation: + 1. Look up `account.account` using provider `discord` and the Discord user ID. + 2. If linked, update that existing user's latest profile information. + 3. If not linked, upsert `account.user` by unique `discord_id`. + 4. Insert the Discord `account.account` link using its existing composite conflict key. + 5. Reload and return the database winner after a concurrent conflict. +- Invoke the same reconciliation during Discord OAuth before NextAuth completes account handling. +- Bot profile updates must not erase an existing email address. +- Verified OAuth login may refresh the email, global display name, avatar, and account token fields. + +## Authentication + +- `/share` checks the NextAuth session on the server before rendering upload controls. +- Unauthenticated visitors receive a non-dismissible Discord login dialog. +- Do not render paste, description, image, or upload controls before login. +- The web upload handler repeats authentication and returns HTTP 401 without a valid session. +- `/share/[id]` remains publicly readable. +- Reading comments remains public, but posting comments requires Discord login. + +## Web Upload + +The authenticated `/share` page should remain visually simple: + +- One large `Paste your text here` textarea. +- Paste, Browse, and Drop support for `.txt` files. +- A byte counter against the 5 MiB limit. +- A smaller optional description field. +- One optional image picker with preview and remove actions. +- One clear Thai upload button: `อัปโหลดและสร้างลิงก์`. +- Navigate directly to `/share/[id]` after success. + +Validation: + +- Main text is required and must contain non-whitespace content. +- Main text may be at most 5 MiB of UTF-8 data. +- Description may be at most 5,000 characters. +- Image is optional and may be PNG, JPEG, WebP, or GIF. +- Image contents must match their declared type. +- Image size may be at most 50 MiB. + +## Private Bot API + +`POST /api/share` is private and reserved for the trusted Discord bot. + +Authentication: + +```http +Authorization: Bearer +``` + +Multipart fields: + +| Field | Required | Description | +| --- | --- | --- | +| `file` | Yes | UTF-8 `.txt` attachment, maximum 5 MiB | +| `senderDiscordId` | Yes | ID of the Discord message author | +| `description` | No | Plain text, maximum 5,000 characters | +| `image` | No | Validated image, maximum 50 MiB | + +The endpoint must: + +1. Verify the bearer secret. +2. Reject files without a case-insensitive `.txt` extension. +3. Reject empty, invalid UTF-8, NUL-containing, or oversized files. +4. Confirm the sender belongs to the configured Discord guild. +5. Fetch the sender's global Discord profile server-side. +6. Use `global_name`, falling back to `username`. +7. Use the global Discord avatar rather than a guild-specific avatar. +8. Ignore and reject request-provided display-name or avatar fields. +9. Synchronize the sender with the existing NextAuth account tables. +10. Store the optional image and share in one transaction. + +Successful response: + +```json +{ + "success": true, + "share": { + "id": "a1b2c3d4", + "url": "https://example.com/share/a1b2c3d4", + "author": { + "displayName": "Example User", + "avatarUrl": "https://cdn.discordapp.com/..." + }, + "imageUrl": null, + "createdAt": "2026-08-16T00:00:00.000Z" + } +} +``` + +Expected statuses: + +- `201` share created. +- `400` malformed or blank content. +- `401` invalid bot secret or missing web session. +- `413` text or image exceeds its limit. +- `415` unsupported file or image type. +- `422` invalid sender or sender is not a guild member. +- `429` rate limit exceeded, including `Retry-After`. +- `503` Discord profile lookup or rate-limit storage is unavailable. + +## Rate Limits + +- Combined web and bot uploads: 10 shares/hour per attributed Discord sender. +- Private bot endpoint: 60 requests/hour globally. +- Comments: 30 comments/hour per Discord account. +- Store fixed-window counters in Redis. +- Return HTTP 429 with `Retry-After` when exceeded. +- Return HTTP 503 if Redis is unavailable instead of accepting unlimited uploads. + +## Shared Page + +`/share/[id]` displays: + +- Author global display name and avatar snapshot. +- Bangkok-localized creation timestamp. +- Optional description in its own plain-text section. +- Optional image beneath the description. +- Main content in a whitespace-preserving monospaced panel. +- `คัดลอกข้อความ` to copy the exact main text. +- `คัดลอกลิงก์` to copy the page URL. +- Delete control for the author or configured administrators. + +The text copy action must use only `share.text.content`. It must preserve whitespace and must never append or prepend the description. + +## Comments + +- Everyone may read comments. +- Discord login is required to post. +- Comment body maximum is 2,000 characters. +- Default order is oldest first. +- Allow switching between oldest and newest. +- Use 50 comments per page. +- Use `comments=oldest|newest` and `page` query parameters. +- Comment authors and configured administrators may delete comments. +- Deleting a share cascades to all comments. + +## Login Dialog Extraction + +- Move `LoginDialog` from `app/form/client.tsx` to `components/login-dialog.tsx`. +- Preserve the form dialog's current behavior and Thai copy as defaults. +- Add optional `title` and `description` props. +- Update the form page to import it from `@/components/login-dialog`. +- Use share-specific Thai copy on `/share` explaining that login is required before uploading. +- Keep the dialog non-dismissible and return users to the original URL after OAuth. +- Remove only imports made unused by the extraction. + +## Verification + +- Verify unauthenticated `/share` requests do not render upload controls. +- Verify direct unauthenticated web upload requests return HTTP 401. +- Verify OAuth returns to `/share` and reveals the form. +- Test existing-user updates, new bot sender creation, concurrent first uploads, and OAuth login after a bot-created account. +- Assert that each Discord ID resolves to exactly one `account.user` and one Discord `account.account` link. +- Test `.txt`, UTF-8, NUL, text, description, and image boundaries. +- Test bot authorization, sender verification, atomic rollback, and response shapes. +- Test rate limits at 10/11 user shares, 60/61 bot requests, and 30/31 comments. +- Test that Copy text preserves the content exactly and excludes the description. +- Regression-test the extracted form login dialog. +- Run: + - `bun test` + - `bunx tsc --noEmit` + - `bun run lint` + - `git diff --check` +- Confirm Drizzle reports no pending schema difference after migration generation. + +## Scope Boundaries + +- The Discord bot implementation is outside this repository. +- The bot supplies `message.author.id` when forwarding a `.txt` attachment. +- Shared pages remain until deleted. +- No editing, automatic expiration, public share feed, search, anonymous publishing, or anonymous comments are included.