docs: add authenticated sharing plan
This commit is contained in:
@@ -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<br/>Paste or drop .txt<br/>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<br/>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<br/>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<br/>Whitespace-preserving monospace"]
|
||||||
|
E["Copy text<br/>Copies main text only"]
|
||||||
|
F["Copy link<br/>Copies page URL"]
|
||||||
|
G["Comments<br/>Oldest or newest · 50 per page"]
|
||||||
|
H["Public to read<br/>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 <SHARE_BOT_SECRET>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user