Data Contracts

Data-Contracts.md

Data Contracts

Big Rule

runtime data lives under /data.

repo intent:

  • /data is not supposed to be versioned normally
  • .gitignore ignores /data/**/*
  • .rsyncignore excludes /data/** from deployment sync

so local/dev/prod data has to be managed separately.

data/accounts/

accounts.json

expected top-level shape:

{
  "accounts": [
    {
      "username": "string",
      "name": "string",
      "password": "bcrypt-hash or empty",
      "isAdmin": true,
      "mustResetPassword": false,
      "discordUserId": "optional discord snowflake string",
      "allowedPages": ["feed", "journal", "comments", "chat"],
      "bookmarks": ["2026-01-01_12-00-00", "journal:12"],
      "theme": "default|classic|theme-id",
      "glowIntensity": "none|low|medium|high",
      "mobileFriendlyView": true,
      "onekoEnabled": true,
      "colors": {
        "bg": "#RRGGBB",
        "fg": "#RRGGBB",
        "border": "#RRGGBB",
        "subtle": "#RRGGBB",
        "links": "#RRGGBB"
      }
    }
  ]
}

notes:

  • extra unknown keys can exist and are preserved by account/admin/edit
  • bookmarks are the current source of truth for logged-in users
  • bookmark ids currently use raw feed ids and journal:{id}; legacy newsletter:{id} values can exist but are ignored
  • theme: default is blackprint and uses the base template plus /style.css; theme: classic enables saved colors; any other valid value refers to a /themes/{theme-id}.json file
  • legacy blackprint normalizes to default, custom normalizes to classic, and newsprint normalizes to whiteprint
  • mustResetPassword is used by the shared session bootstrap to force first-login password changes
  • discordUserId links a site account to a Discord member for bot DMs and notifications
  • allowedPages currently includes functional grants like feed, journal, comments, and chat

data/chat/

one-time private conversations live as individual encrypted JSON envelopes:

{
  "version": 1,
  "cipher": "aes-256-gcm",
  "nonce": "base64",
  "tag": "base64",
  "ciphertext": "base64"
}

notes:

  • each file is named {conversationId}.json; new chat ids are 9 lowercase letters/numbers
  • legacy 32-character lowercase hex ids are still accepted so older active links do not break
  • decrypted payloads contain conversation metadata, the recipient label in name, the recipient cookie hash, and message records
  • messages may include an attachment object with encrypted blob metadata: id, name, mime, and size; image/audio/video attachments are served inline through the authorized chat route so they can render or play in the chat UI
  • messages may include replyTo with another message id, plus reactions keyed by valid emoji sequences with active viewer roles such as manager or participant
  • conversations may include participantUsername when a logged-in account claims the invite, or participantHash when an anonymous browser cookie claims it; the first-open popup copy changes based on that claim type
  • conversations may include recipientIntroSeenAt once the recipient has seen the first-open security/help popup
  • recipient cookies are HttpOnly and scoped to /chat
  • the first non-manager account or anonymous browser to open /chat/{conversationId} claims the recipient slot
  • account-linked recipients can delete their own active chat; anonymous cookie-linked recipients cannot
  • admins and accounts with allowedPages containing chat can create, view, and delete conversations without claiming the recipient slot
  • deleting a conversation unlinks the encrypted JSON file immediately
  • encryption uses FRIDG3_CHAT_KEY when set; otherwise the app creates data/chat/.chat_key
  • lightweight presence indicators use sidecar files under data/chat/.presence/{conversationId}.json; current entries store lastSeen, active, and a short-lived typingUntil, while older timestamp-only entries are still readable
  • attachments are encrypted AES-256-GCM envelopes under data/chat/.attachments/{conversationId}/; they are served only through the authorized chat route and are deleted with the conversation
  • attachment uploads are capped at 8 MB

/themes/

theme metadata lives as JSON files directly under /themes.

{
  "name": "Theme Name",
  "html": "template-file.html",
  "css": "stylesheet-file.css"
}

notes:

  • the metadata filename is the saved theme id, for example /themes/cool.json becomes cool
  • name is the label shown in /settings
  • html and css must be relative paths in /themes/lib, for example aero/aero.html and aero/aero.css
  • theme asset paths cannot be absolute, contain .., or use characters outside letters, numbers, ., _, -, and /
  • desktop rendering uses both themed HTML and CSS
  • mobile rendering keeps template_mobile.html and only swaps the CSS

login_attempts.json

  • map of client IP -> unix timestamp array
  • used for login throttling

data/feed/

feed post format:

  1. @username
  2. YYYY-MM-DD HH:MM:SS
  3. body text / BBCode

other file:

  • index.toml is generated by /feed/index.php

feed bodies can include public voice notes as BBCode:

[audio=/data/audio/voice/example.m4a][name:voice-note.m4a]

voice notes are created from temporary [voice:N] editor placeholders, verified at upload time, transcoded to small mono .m4a files, and stored under data/audio/voice/.

data/feed/replies/

per-post replies live in {postId}.json files shaped roughly like:

{
  "replies": [
    {
      "id": "20260413153000_deadbeef",
      "username": "toast",
      "date": "2026-04-13 15:30:00",
      "body": "reply body with BBCode"
    }
  ]
}

notes:

  • reply ids are generated on write; older data may be normalized into legacy_* ids at read time
  • reply bodies can contain image BBCode that points at /data/images/*
  • new reply bodies can also contain voice note audio BBCode that points at /data/audio/voice/*

data/journal/

published journal post:

  1. YYYY-MM-DD
  2. title
  3. description
  4. trusted HTML body

draft format:

  1. USER:<username>
  2. title
  3. description
  4. optional FORMAT:html
  5. draft body

without FORMAT:html, preview treats the body as BBCode. with it, preview treats the body as raw HTML.

data/guestbook/

entry format:

  1. timestamp
  2. display name
  3. message body

plus:

  • ip_index.json for one-post-per-IP ownership tracking

data/images/

  • uploaded images used across feed, journal, and gallery content
  • expected web path is /data/images/<filename>

data/music/

artist folders currently include:

  • frdg3
  • cactile

album JSON shape:

{
  "album_name": "string",
  "album_caption": "string",
  "album_type": "Album|EP|Single|Remix|...",
  "album_art": "/data/images/example.jpg",
  "album_art_directory": "/data/images/example.jpg",
  "order": 6,
  "songs": [
    { "name": "Track", "directory": "/data/audio/file.wav" }
  ]
}

album_art_directory is preferred by current code.

data/audio/

  • track files referenced by music metadata
  • also used by shared playback features
  • data/audio/voice/ stores public feed voice notes as compressed .m4a files

data/contact/

  • private contact submissions as {YYYYMMDDHHMMSS}_{random}.json
  • each submission stores id, createdAt, hashed IP, user agent, name, email, message, notification channel id, and optional notifyError
  • rate_limits.json stores hashed client IP keys mapped to recent submission timestamps for throttling
  • nginx blocks direct web access to this directory; submissions are only shown through the admin-only /contact?dashboard=1 route

data/mdpaste/

  • temporary markdown paste records as {id}.json
  • ids are 16 lowercase hex characters
  • records expire after 30 days and are cleaned up opportunistically on create/view
  • unencrypted records store a markdown string
  • encrypted records store only AES-256-GCM ciphertext plus PBKDF2-SHA256 salt/nonce/tag metadata; the password is never stored
  • hard_breaks controls whether single paragraph newlines render as <br> instead of spaces

data/etc/

wip

  • plain text maintenance flag

webhooks.json

used key:

{
  "discord_feed": "https://discord.com/api/webhooks/..."
}

toast.json

expected shape:

{
  "bot": { "token": "...", "client_id": "...", "status": "online|offline" },
  "stream": { "url": "http(s)://...", "name": "..." },
  "channel": { "id": "...", "name": "..." },
  "features": { "auto_play": true, "loop": true }
}

toast-updates.json

  • array of timestamped bot status entries

toast-feed-notify-state.json

  • internal bot dedupe state for sent feed mention/reply notifications
  • stores which feed mentions and replies have already triggered DMs

toast-dm-history.json

  • tracked inbound/outbound DM threads used by /others/toast-discord-bot/messages
  • stores per-user profile snapshot data plus message history

contact notification endpoint

  • the toast bot exposes localhost-only POST /contact/notify on 127.0.0.1:8765
  • /contact calls it after saving a submission
  • toast sends the alert to Discord channel 1503931489560301609

off-topic-archive.json

  • Discord export blob used by the archive viewer

page_views.json

shape is roughly:

{
  "pages": {
    "/": {
      "count": 12,
      "visitors": {
        "<sha256>": 1730931224
      }
    }
  },
  "updated_at": "2026-03-02T00:00:00Z"
}

data/downloads/

  • downloadable binaries, archives, presets, and similar files linked from the site