Home Doh Ref
Dohballs
  • 📁 doh_chat
    • 📦 chat_extensions
  • 📁 doh_modules
    • 📦 dataforge
    • 📦 express
    • 📁 sso
    • 📁 user
  • 📁 sites
    • 📁 cec
      • 📦 game

Chat Files

Peer-to-peer file transfer with WebRTC-first delivery and socket relay fallback.

Overview

chat_files provides file sharing within chat rooms. Files transfer directly between peers via WebRTC data channels when possible, falling back to a server-mediated socket relay when P2P connections can't be established. Files are cached locally in IndexedDB, and users who have downloaded a file become seeders for other peers. The module handles thumbnails for images, inline previews for media/PDF/text, and synchronized media playback across room participants.

When server seeding is enabled (the default), the uploader can also push files to the server for persistent availability. Users claim permanent server storage within the temp-cache window (infinite by default, configurable via temp_cache_minutes) and are charged against their tier quota.

Architecture

chat_files_server (nodejs)     chat_files_client (browser)
├── File metadata DB           ├── StorageMirrorRegistry (plugin system)
├── Seeder registry            ├── TransferManager (orchestration)
├── Socket signal relay        ├── FileCache (IndexedDB)
├── Relay chunk routing        ├── ChunkAccumulator
├── Server seeding (multer)    ├── ChatFileCard (UI)
└── Tier quota enforcement     └── WebRTC DataChannel

Loaded by: chat.doh.yaml (no standalone package — part of core chat) Depends on: chat_server (rooms, messages, membership)

File Structure

File Purpose
chat_files_server.js Socket events, DB tables, file validation, server seeding (~1455 lines)
chat_files_client.js Transfer orchestration, caching, UI, StorageMirrorRegistry (~2490 lines)
chat_files.css File card and preview styles

Configuration

Key Type Default Description
chat_files.chunk_size number 65536 (64KB) Bytes per transfer chunk
chat_files.max_file_size number 524288000 (500MB) Maximum file size
chat_files.max_thumbnail_size number 51200 (50KB) Max thumbnail base64 size
chat_files.webrtc_timeout number 10000 (10s) WebRTC connection timeout before relay fallback
chat_files.relay_batch_size number 8 Chunks per relay batch before ack
chat_files.blob_merge_threshold number 10485760 (10MB) ChunkAccumulator merge-to-Blob threshold
chat_files.image_auto_download_max number 1048576 (1MB) Auto-download threshold for images
chat_files.cache_max_bytes number 500 IndexedDB cache limit (MB)
chat_files.probe_timeout number 3000 (3s) Peer probe timeout
chat_files.peer_wait_timeout number 60000 (60s) Passive wait for seeders timeout
chat_files.ice_servers array Google STUN servers WebRTC ICE server list
# pod.yaml override example
chat_files:
  max_file_size: 1073741824   # 1GB
  cache_max_bytes: 1000       # 1GB IndexedDB cache
  ice_servers:
    - urls: 'stun:stun.example.com:3478'
    - urls: 'turn:turn.example.com:3478'
      username: 'user'
      credential: 'pass'

Server Seeding Configuration

The chat_files.server_seeding block controls the server-side file storage system. All sub-keys are optional and have defaults.

Key Type Default Description
chat_files.server_seeding.enabled boolean true Enable server seeding. Set to false to disable upload routes
chat_files.server_seeding.storage_path string '/uploads/chat_files' DohPath-resolved directory for stored files
chat_files.server_seeding.max_total_storage_bytes number 10737418240 (10GB) Server-level cap. When exceeded, unclaimed temp files are evicted oldest-first. Permanently stored (paid) files are never evicted automatically.
chat_files.server_seeding.temp_cache_minutes number or false false (infinite) How long a freshly uploaded file is held in temp cache before a claim is required. false disables expiry (files persist until manually removed or server cap forces eviction). Set to a number (e.g. 30) to auto-delete unclaimed files after that many minutes.
chat_files.server_seeding.cleanup_interval_hours number 1 How often the cleanup job runs
chat_files.server_seeding.allowed_mime_types array or null null MIME type allowlist. null means all types are accepted

Retention policy: Permanently stored files (those the user explicitly claimed via store-permanently) are never auto-deleted. When temp_cache_minutes is set to a number, unclaimed temp-cache files are deleted after that window. When temp_cache_minutes is false (the default), unclaimed files persist indefinitely unless the server cap forces eviction (oldest-first among unclaimed files). Users release their own permanent storage via POST /api/chat/files/remove-from-server. If the server cap is reached and all remaining files are permanently claimed, new temp uploads are blocked until space is freed.

# pod.yaml
chat_files:
  server_seeding:
    enabled: true
    storage_path: '/uploads/chat_files'
    max_total_storage_bytes: 10737418240
    temp_cache_minutes: false       # false = infinite (no expiry); set to a number for auto-cleanup
    cleanup_interval_hours: 1
    allowed_mime_types: null    # or ['image/png', 'image/jpeg', 'video/mp4']

Upload Tier Quotas

The chat_files.upload_tiers block maps permission group names to per-user permanent storage limits. The default key applies to all users not in a named group.

Tier key Default limit Who gets it
default 104857600 (100MB) All authenticated users
doh_pro 5368709120 (5GB) Users in the doh_pro permission group

Enforcement happens at the POST /api/chat/files/store-permanently route. The server checks the user's total_stored_bytes (tracked in chat.file_user_status) against their tier limit before accepting a claim. If the new file would exceed the limit, the route returns HTTP 403 with a message showing current usage and the tier cap.

To configure custom tiers, add new group-name keys to upload_tiers. The tier name must match an existing permission group name:

# pod.yaml
chat_files:
  upload_tiers:
    default: 104857600       # 100MB
    doh_pro: 5368709120      # 5GB
    enterprise: 107374182400 # 100GB — requires 'enterprise' permission group

Database Schema

Table Type Purpose
chat.files Idea File metadata registry
chat.file_seeds Idea Seeder availability tracking (peer + server)
chat.file_storage_claims Idea User permanent storage claims
chat.file_user_status Idea Per-user quota tracking and suspension flags

File metadata shape (in chat.files):

{
  id: string,            // UUID
  room_id: string,
  name: string,          // Original filename
  size: number,          // Bytes
  mime_type: string,
  chunk_size: number,
  total_chunks: number,
  thumbnail: string,     // Base64 data URL (images only, capped at 50KB) or null
  sender: string,        // Username
  created_at: number,    // Timestamp
  md5: string,           // MD5 hash (set after server upload)
  sanitized_name: string // Filesystem-safe name (set after server upload)
}

Seeder record shape (in chat.file_seeds):

{
  id: string,            // "{file_id}:{username}" or "{file_id}:__server__"
  file_id: string,
  username: string,      // "__server__" for server-side seeder
  seeded_at: number,
  // Server seeder additional fields:
  stored: boolean,
  permanent: boolean,    // true once a user claims permanent storage
  cached_at: number,
  size: number,
  md5: string,
  storage_file_id: string  // Physical file ID (may differ from file_id after dedup)
}

Storage claim shape (in chat.file_storage_claims):

{
  id: string,            // "{file_id}:{username}"
  file_id: string,
  username: string,
  claimed_at: number,
  size: number
}

File message format (in chat.messages.content):

[FILE]{"file_id":"...","name":"...","size":123,"mime_type":"...","thumbnail":"..."}

REST API Reference

Method Path Auth Description
GET /api/chat/files/user Yes List user's files across all rooms
POST /api/chat/files/upload Yes Upload file to server temp cache (HTTP only)
POST /api/chat/files/store-permanently Yes Claim permanent server storage for a file
POST /api/chat/files/remove-from-server Yes Release permanent storage claim
GET /api/chat/files/user/storage Yes Get user's storage usage and claimed files
GET /api/chat/files/:file_id/download Optional Download file from server (HTTP only, supports range requests)

GET /api/chat/files/user

// Query params
{ room_id?: string, search?: string }
// Response
{
  files: [{
    id, name, size, mime_type, thumbnail, created_at,
    room_id, room_name, room_type, sender_chat_name
  }]
}

POST /api/chat/files/upload

Must be a real HTTP request (not socket). The global Express middleware must have parsed the multipart body. The file is written to STORAGE_PATH/{file_id} and registered as a __server__ seeder.

// Multipart form fields
file_id: string   // UUID from prior chat:file:share socket event
file: File        // Binary file data

// Response
{ success: true, file_id, expires_at: number, deduped: boolean }

MD5 deduplication is applied: if the server already holds a file with the same hash, the new upload is discarded and the existing physical file is reused.

POST /api/chat/files/store-permanently

// Request
{ file_id: string }
// Response (success)
{ success: true, total_stored_bytes, tier_limit, tier_name }
// Response (quota exceeded, 403)
{ error: "Storage limit exceeded. Using 95MB of 100MB (Free tier)." }

GET /api/chat/files/user/storage

// Response
{
  total_stored_bytes: number,
  tier_name: string,          // 'Free', 'doh_pro', etc.
  tier_limit: number,
  claimed_files: [{
    file_id, name, size, room_id, created_at, claimed_at
  }]
}

Socket Events Reference

File Sharing

Event Direction Payload Response
chat:file:share Client → Server { room_id, name, size, mime_type?, thumbnail? } cb({ success, file_id })

Side effects: creates chat.files entry, registers sender as seeder, creates [FILE] message, broadcasts chat:message_received.

Peer Discovery

Event Direction Payload Response
chat:file:request_peers Client → Server { file_id, room_id } cb({ success, seeders[], file, diag })
chat:file:server_status Client → Server { file_id } cb({ success, server_available, user_has_claim, expires_at })
chat:file:probe Client → Server { file_id, room_id } cb({ success }) — broadcasts to room
chat:file:probe_response Client → Server { file_id, has_file, requester_socket_id } Routed to requester

chat:file:request_peers — Full peer discovery: queries all seeds for the file, fetches live room sockets, resolves which seeders are currently online, checks server availability and user claim. Used by TransferManager when initiating a download.

chat:file:server_status — Lightweight server-only check: two targeted ID lookups (FILE_SEEDS for {file_id}:__server__ and FILE_STORAGE_CLAIMS for {file_id}:{username}). No socket fetch, no peer resolution. Used by ChatFileCard._setupServerStorageUI() at card init to populate the server storage UI without triggering a full peer query per file on room load.

WebRTC Signaling

Event Direction Payload Response
chat:file:signal Client → Server { target_username, room_id, file_id, signal_type, signal_data } Relayed to target peer

signal_type: 'offer' | 'answer' | 'ice-candidate'

Socket Relay Transfer

Event Direction Payload Response
chat:file:relay_request Client → Server { file_id, room_id, source_username } cb({ success, transport: 'relay' })
chat:file:relay_chunk Source → Server → Requester { requester_socket_id, file_id, chunk_index, chunk_data, total_chunks } Relayed
chat:file:relay_ack Requester → Server → Source { source_socket_id, file_id, received_through } Relayed
chat:file:relay_complete Source → Server → Requester { requester_socket_id, file_id } Relayed

Relay chunks are base64-encoded. Acks sent every relay_batch_size (8) chunks.

Seeding & Sync

Event Direction Payload Response
chat:file:seed_registered Client → Server { file_id } cb({ success }) — broadcasts to room
chat:file:server_cached Client → Server { file_id, room_id, expires_at } cb({ success }) — broadcasts to room
chat:update_file_preview_size Client → Server { message_id, file_id, width, height, isLive } Broadcasts to room
chat:media_sync Client → Server { room_id, file_id, action, time } Broadcast (excludes sender)

action: 'play' | 'pause' | 'seek'

Transfer Flow

1. Check local memory (localFiles Map)
   └─ Found → complete (instant)

2. Check IndexedDB cache (FileCache)
   └─ Found → complete, register as seeder

3. Probe room peers (chat:file:probe, 3s timeout)
   └─ Peer responds → attempt transfer

4. Query seed registry (chat:file:request_peers)
   └─ Seeders found → attempt transfer

5. Wait passively (60s) for new seeders
   └─ chat:file:seeder_available / chat:room_peer_joined
   └─ Timeout → 'unavailable'

Transfer attempt:
  a. Try WebRTC DataChannel (10s timeout)
     └─ Success → complete, cache, register seeder
     └─ Fail → try next method

  b. Try socket relay (base64 chunks, batched with ack)
     └─ Success → complete, cache, register seeder
     └─ Fail → try next seeder, then give up

Client Architecture

StorageMirrorRegistry

A plugin system for storage backends. Each plugin tracks whether a file is stored and provides context menu actions. Plugins are registered via StorageMirrorRegistry.register(plugin).

Method Description
register(plugin) Add a storage plugin
getPlugins() List all registered plugins
getPlugin(id) Get plugin by ID
getManagingPlugins(fileId) Get plugins that currently store a file

Built-in plugins:

Plugin ID Description
local LocalStoragePlugin — IndexedDB cache (always registered)
server ServerStoragePlugin — Server seeding (registered only when server_seeding_enabled is true in browser_pod)

Plugins expose isStored(fileId), queryStatus(fileId), and getContextMenuItems(fileId, card) for UI integration. ChatFileCard uses the registry to render storage indicator icons and context menu actions.

TransferManager (Singleton)

Orchestrates all file transfers. Key methods:

Method Description
shareFile(roomId, file) Validate, thumbnail, emit share, cache locally
requestFile(fileId, roomId) Start transfer cascade (cache → probe → peers → wait)
cancelTransfer(fileId) Cancel active transfer, cleanup WebRTC
loadCachedFilesForRoom(roomId) Load from IndexedDB, re-register as seeder

FileCache (IndexedDB)

Method Description
put(fileId, blob, metadata) Store with LRU eviction
get(fileId) Retrieve blob
loadRoom(roomId, localFilesMap) Load all cached files for a room
clear() Wipe cache
getStats() { count, bytes }

DB: doh_chat_files, store: files, indices: room_id, cached_at

ChunkAccumulator

Efficiently accumulates binary chunks, merging to Blob at the blob_merge_threshold to prevent memory pressure on large files.

ChatFileCard Pattern

Property Type Description
file_id string File identifier
file_name string Display name
file_size number Bytes
mime_type string MIME type
thumbnail string Base64 data URL or null
room_id string Room the file belongs to
_state string 'available' | 'connecting' | 'transferring' | 'complete' | 'error' | 'unavailable'
_progress number 0-100 transfer progress
_browseMode boolean When true: skips TransferManager registration, auto-download, and server storage UI. Set by ChatRoomFileViewer when rendering cards in the files browser.
_serverCached boolean Server holds a temp or permanent copy
_serverExpiresAt number | null Timestamp when temp cache expires (null if permanent)
_userHasClaim boolean Current user has a permanent storage claim on this file

Sub-objects: thumbEl, info (nameEl, metaEl), progressBar (fill), actionArea (downloadBtn, toggleBtn, cancelBtn, statusEl), previewArea, resizeHandle, serverStorageArea (storeBtn, storedIndicator, removeBtn)

Supports inline preview for images, video, audio, PDF, and text files. Previews are resizable with cross-peer size synchronization.

Server storage UI lifecycle (non-browse mode, server_seeding_enabled only):

  1. post_builder_phase calls _setupServerStorageUI()
  2. Subscribes to chat:file:server_cached broadcasts (updates UI if server caches this file while card is live)
  3. Emits chat:file:server_status once to get current server_available, user_has_claim, and expires_at — populates initial UI state

Files browser view (ChatRoomFileViewer in chat_client.js) renders cards with _browseMode: true, which skips all of the above. It determines server storage state separately: a single GET /api/chat/files/user/storage call returns the user's claimed_files array, from which it builds a _serverClaims map used for filtering and the "saved on server" indicator. Only permanently claimed files appear in this view — temp-cached-only files do not.

Per-window state: ChatRoomFileViewer accepts a _storagePrefix property (default 'chat-files') used as the localStorage key prefix for view mode, type filters, and storage filters. FilesDohtopContent sets this to 'chat-files-<windowId>' so each Files DohtopWindow instance has independent filter state.

Orphaned files: /api/chat/files/user also returns files uploaded by the current user whose room has been deleted. These are enriched with room_type: 'deleted' and room_deleted: true. The file browser groups them under a collapsed "Deleted Rooms" folder in the sidebar.

Room type enrichment: File responses include room_type (from the room object) and partner_username (for DM rooms), enabling the files sidebar to render proper room icons (format icons, DM avatars, bot gear, issue bug) via ChatRoomStore and ChatUserStore.

Testing

  1. Share a file in a chat room — verify [FILE] message appears
  2. Open a second browser/tab in the same room — download the file
  3. Verify WebRTC transfer (check console for "WebRTC" transport label)
  4. Test with WebRTC blocked (e.g., restrictive NAT) — verify socket relay fallback
  5. Close all tabs, reopen — verify cached file loads from IndexedDB
  6. Test media sync: play a video in one tab, verify playback syncs to other tabs
  7. Test server seeding: upload a file, verify POST /api/chat/files/upload succeeds, then call store-permanently and check /api/chat/files/user/storage reflects the new total
  8. Verify quota enforcement: attempt to permanently store a file that would exceed the tier limit — expect HTTP 403 with usage message
Last updated: 3/27/2026