Files

12 KiB

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

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

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

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

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:

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:

{
  "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.