Compare commits
186 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02db6c719c | |||
| df2a992fa7 | |||
| 410b7a4d6b | |||
| 5eeb659b70 | |||
| 13e3aec2d5 | |||
| 83c6badc20 | |||
| 43b20c5ce3 | |||
| b0087e12af | |||
| 1a1f2fc99c | |||
| e927dd2cb0 | |||
| beb04196ca | |||
| a54a67e41b | |||
| 86717a2867 | |||
| 4a416427e9 | |||
| 8261555026 | |||
| 3cde62bdc6 | |||
| 065f036807 | |||
| f53cd49803 | |||
| 4e48815b91 | |||
| d9b3162f1c | |||
| 6384588122 | |||
| 978e94da90 | |||
| 34c18c13ae | |||
| cca6ea0e37 | |||
| 738b9b17ff | |||
| 44370f41e5 | |||
| 1d6c9bdfe6 | |||
| 57aec2c6b3 | |||
| d4ca5f576e | |||
| 5f2b0aea8b | |||
| 6f6d1329c6 | |||
| 156ee4197c | |||
| a5882f32c4 | |||
| 516168d290 | |||
| c2be53e53f | |||
| 953a1e26e6 | |||
| 5d9b629dca | |||
| 4cc6c6f1ed | |||
| 82f329808e | |||
| 0ab08a9374 | |||
| c9884d1cbd | |||
| 974eca4bb2 | |||
| 8437ddcb7f | |||
| aa0af5db4f | |||
| e79505d8b4 | |||
| b0d820cd92 | |||
| a8cb5c5933 | |||
| bda4c9d73d | |||
| d4fff01e35 | |||
| 77313671b6 | |||
| e76f755abb | |||
| 3bc8f9721e | |||
| a66b777712 | |||
| 19386c2b33 | |||
| e7deb84660 | |||
| 76230bb9d6 | |||
| 908b87eb5d | |||
| e62f551e2f | |||
| 83409cef0d | |||
| a99714eec6 | |||
| 6a24b6f335 | |||
| 87a49118b5 | |||
| 300aeb219f | |||
| 0f19b9089d | |||
| 3c7b8278ce | |||
| a277c78e2c | |||
| 038ac1fe8e | |||
| 07c72b041d | |||
| f6322fb779 | |||
| 49ea7c4e3b | |||
| 95cbcf79a4 | |||
| 3fd0b0e9e2 | |||
| 97c5d81d70 | |||
| f9af9ae15d | |||
| 5127144709 | |||
| 3d31054626 | |||
| 7c70082f37 | |||
| 2674c254a1 | |||
| 64df016f3f | |||
| 2f55521187 | |||
| b23e9ffc6f | |||
| fb9e3e39a8 | |||
| c55ab2a8bb | |||
| 01dd273bd5 | |||
| e1c20044ac | |||
| 3783b37df8 | |||
| 9a5594da4f | |||
| b325d5185b | |||
| 57615f5117 | |||
| d20c0da498 | |||
| 19a3ea489b | |||
| a086a575be | |||
| 83eb6ea2f7 | |||
| c83319ba37 | |||
| 1ab5cfd721 | |||
| 62b8097746 | |||
| 2025d763f9 | |||
| c8151c6e38 | |||
| 6d08016451 | |||
| ca64608e12 | |||
| a91720d045 | |||
| a3064b94ab | |||
| d033a3e088 | |||
| 4e429ca920 | |||
| c9a213ab8a | |||
| cb4df31f43 | |||
| d3756b5f47 | |||
| c731101bc7 | |||
| b0248571f4 | |||
| e6a3eea181 | |||
| c65e93f9ba | |||
| d069707473 | |||
| 0fa0227013 | |||
| 1ac176eb9f | |||
| 90e9c24d81 | |||
| 682bfd9c6a | |||
| b360832128 | |||
| 30b24a5f9a | |||
| ee7c4c4144 | |||
| a4dbca093e | |||
| 4adbffab0c | |||
| eb2ded9614 | |||
| 126c0323ee | |||
| 0d822803f9 | |||
| 8222cc9502 | |||
| 0427b0e624 | |||
| fde0bfef37 | |||
| 7277614ae5 | |||
| 883b775c2f | |||
| bca2ad5bcd | |||
| de53da1c76 | |||
| d416d5d926 | |||
| 41ae10ad64 | |||
| e80334e336 | |||
| 0bd049f52a | |||
| c7df642876 | |||
| fffa422d16 | |||
| d17eab3f11 | |||
| 76e430aa22 | |||
| f5e767cdbb | |||
| 6bf21a81f7 | |||
| 8311ecaf7f | |||
| d000b06748 | |||
| b2726bdc5a | |||
| 2deb4acc0f | |||
| cca1f1e619 | |||
| 0f54657b23 | |||
| 6654a9ee6b | |||
| 174134630e | |||
| eee4bcaa8d | |||
| a82fd7a473 | |||
| a8532fea21 | |||
| b03dcb05a8 | |||
| 73c2f4f01e | |||
| fe8ad61143 | |||
| 5d9b29a89b | |||
| 6d0a1fd15f | |||
| 199be07961 | |||
| 7c6037bd93 | |||
| 64267cbdd0 | |||
| 52f949fece | |||
| aad0eab716 | |||
| 17ee59c9c4 | |||
| b28e072e81 | |||
| f4c5fae10a | |||
| f6403339fd | |||
| c57889e477 | |||
| 9ee7b9e587 | |||
| 5462f74d03 | |||
| 5153ad7210 | |||
| 3fee0e5d43 | |||
| b22d231a18 | |||
| 6d97d28237 | |||
| e951e4b922 | |||
| ac98f73616 | |||
| d2c5830466 | |||
| bf3bf2392c | |||
| 37d8b82d5b | |||
| 5f95fa6357 | |||
| 086ed99ae0 | |||
| eaf8d0d8fa | |||
| 0c25e42bc3 | |||
| 876ae5c227 | |||
| d5f0310218 | |||
| 1d81b811bd | |||
| 413c423ad1 |
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
node_modules
|
||||
web/dist
|
||||
certs
|
||||
*.zip
|
||||
dumpster-server
|
||||
.env
|
||||
.env.local
|
||||
@@ -1,25 +1,23 @@
|
||||
# App
|
||||
DUMPSTER_HOST=localhost
|
||||
DUMPSTER_PORT=8080
|
||||
DUMPSTER_SECRET=*** Database
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
# dumpsterChat Environment Configuration
|
||||
# Copy this file to .env and fill in your values.
|
||||
|
||||
# Server
|
||||
DUMPSTER_HOST=dumpster.dustin.coffee
|
||||
DUMPSTER_SECRET=generate-a-random-secret-here
|
||||
|
||||
# Database
|
||||
POSTGRES_USER=dumpster
|
||||
POSTGRES_PASSWORD=dumpster
|
||||
POSTGRES_SSLMODE=disable
|
||||
# Valkey
|
||||
VALKEY_URL=redis://localhost:***@example.com
|
||||
# MinIO
|
||||
MINIO_ENDPOINT=minio:9000
|
||||
MINIO_ACCESS_KEY=<generated>
|
||||
MINIO_SECRET_KEY=<gener...n
|
||||
# LiveKit
|
||||
LIVEKIT_URL=wss://livekit.your.domain
|
||||
LIVEKIT_API_KEY=<generated>
|
||||
LIVEKIT_API_SECRET=<gener...n
|
||||
# Web Push
|
||||
VAPID_PUBLIC_KEY=<generated>
|
||||
VAPID_PRIVATE_KEY=<generated>
|
||||
VAPID_SUBJECT=mailto:admin@your.domain
|
||||
# Integrations
|
||||
GIPHY_API_KEY=your_giphy_api_key_here
|
||||
POSTGRES_PASSWORD=generate-a-random-password-here
|
||||
POSTGRES_DB=dumpster
|
||||
|
||||
# MinIO (optional — omit for local filesystem)
|
||||
MINIO_ENDPOINT=
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
|
||||
# LiveKit (WebRTC / voice)
|
||||
LIVEKIT_API_KEY=generate-a-random-key-here
|
||||
LIVEKIT_API_SECRET=generate-a-random-secret-here
|
||||
|
||||
# Giphy (optional — for GIF search)
|
||||
GIPHY_API_KEY=
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
name: Release Desktop Apps
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('web/package-lock.json') }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cache Rust target and registry
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
web/src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('web/src-tauri/Cargo.lock') }}
|
||||
- name: Install system deps
|
||||
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf rpm
|
||||
- name: Build frontend
|
||||
run: cd web && npm ci && npm run build
|
||||
- name: Build Tauri bundles
|
||||
env:
|
||||
NO_STRIP: "true"
|
||||
run: cd web && npx tauri build
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: linux-bundles
|
||||
path: |
|
||||
web/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
web/src-tauri/target/release/bundle/deb/*.deb
|
||||
web/src-tauri/target/release/bundle/rpm/*.rpm
|
||||
|
||||
build-windows:
|
||||
runs-on: windows
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20 }
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('web/package-lock.json') }}
|
||||
- name: Install Rust
|
||||
run: |
|
||||
curl.exe -sLo rustup-init.exe https://win.rustup.rs/x86_64
|
||||
rustup-init.exe -y --default-toolchain stable --profile minimal
|
||||
set PATH=%USERPROFILE%\.cargo\bin;%PATH%
|
||||
rustc --version
|
||||
shell: cmd
|
||||
- name: Cache Rust target and registry
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
web/src-tauri
|
||||
- name: Build frontend
|
||||
run: cd web && npm ci && npm run build
|
||||
shell: cmd
|
||||
- name: Build Tauri bundles
|
||||
env:
|
||||
CI: "true"
|
||||
run: cd web && set PATH=%USERPROFILE%\.cargo\bin;%PATH% && npx tauri build
|
||||
shell: cmd
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: windows-bundles
|
||||
path: |
|
||||
web/src-tauri/target/release/bundle/msi/*.msi
|
||||
web/src-tauri/target/release/bundle/nsis/*.exe
|
||||
|
||||
release:
|
||||
needs: [build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v3
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: |
|
||||
linux-bundles/**/*.AppImage
|
||||
linux-bundles/**/*.deb
|
||||
linux-bundles/**/*.rpm
|
||||
windows-bundles/**/*.msi
|
||||
windows-bundles/**/*.exe
|
||||
@@ -40,3 +40,13 @@ minio_data/
|
||||
/server
|
||||
/migrate
|
||||
/dumpster-server
|
||||
keygen
|
||||
steamfree
|
||||
|
||||
# Tauri Android keystore (contains signing passwords)
|
||||
web/src-tauri/gen/android/keystore.properties
|
||||
|
||||
# Secrets and certificates
|
||||
/certs/
|
||||
cookie.txt
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## vs Discord, Guilded (historical), TeamSpeak 6, Fluxer
|
||||
|
||||
Compiled 2026-06-30. Updated through Phase 7 completion.
|
||||
Compiled 2026-06-30. **Status refreshed 2026-07-15** (not a full re-audit of every row).
|
||||
|
||||
---
|
||||
|
||||
@@ -21,22 +21,23 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
||||
|
||||
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||
|| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|
||||
|| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Threads | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
|| Forum channels | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
|| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
||||
|| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
||||
|| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Message history (pagination) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|| Read receipts | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|
||||
| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Threads | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Forum channels | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
||||
| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
||||
| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Message history (pagination) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Read receipts | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| @everyone / @channel | ✅ (perm gated) | ✅ | ✅ | ❌ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
@@ -68,7 +69,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
||||
| Badges | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Usernames + discriminators | ✅ | ⚠️ handles | ❌ | ✅ UID | ✅ #0000 |
|
||||
| Friend requests | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
|| Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| Activity / game status | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
---
|
||||
@@ -80,11 +81,12 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
||||
| Roles | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ |
|
||||
| Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ |
|
||||
|| Per-channel permission overrides | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Per-channel permission overrides | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ |
|
||||
| Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
||||
| Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Client-side permission gates | ✅ (user roles + @everyone) | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
### dumpsterChat Permissions (current)
|
||||
|
||||
@@ -123,7 +125,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
||||
| Server invites | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Vanity URLs | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
||||
| Webhooks | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Bots / API | ⚠️ slash cmds | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
|
||||
| Bots / API | ⚠️ store + runner | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
|
||||
| Server templates | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
| Server discovery | ❌ | ✅ | ✅ | ✅ | 🔄 |
|
||||
| Server analytics | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
@@ -139,10 +141,10 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
||||
| Desktop notifications | ⚠️ possible via SW | ✅ | ✅ | ✅ | ✅ |
|
||||
| Web push notifications | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| @mention push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Channel-wide push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Channel-wide push (@everyone/@channel) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Email notifications | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Mobile apps | ❌ | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
|
||||
| Per-channel notification settings | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Mobile apps | ❌ (PWA is target) | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
|
||||
| Per-channel notification settings | ⚠️ partial | ✅ | ✅ | ✅ | ✅ |
|
||||
| Do Not Disturb schedule | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
---
|
||||
@@ -156,6 +158,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
||||
| Slash commands | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Command options / JSON schema | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Bot mentions | ✅ | ✅ | ❌ | ❌ | 🔄 |
|
||||
| Built-in bot runner (anonConfess, leaderboard, steamfree) | ✅ | ❌ | ⚠️ | ❌ | ❌ |
|
||||
| Third-party integrations (Twitch, YouTube, GitHub) | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||
| Webhook-driven bots | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
|
||||
@@ -168,40 +171,11 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
||||
- Terminal/Gruvbox aesthetic
|
||||
- WebAuthn / passkey auth
|
||||
- LiveKit voice integration
|
||||
- Built-in webhook execution for simple integrations
|
||||
- Built-in bot store + managed runner
|
||||
- PWA-first mobile (no native app planned)
|
||||
|
||||
### Discord
|
||||
- Massive network effect (200M+ MAU)
|
||||
- Nitro subscription perks (animated avatars, HD streaming, larger uploads)
|
||||
- Activities / embedded apps in voice channels
|
||||
- Server boosting tiers
|
||||
- Stage channels
|
||||
- Activities marketplace
|
||||
|
||||
### Guilded (historical)
|
||||
- Built-in calendar with RSVP
|
||||
- Scheduling + availability system
|
||||
- Docs / forms
|
||||
- Lists (task management)
|
||||
- Tournaments
|
||||
- Server Subs monetization
|
||||
- Server Groups (sub-servers)
|
||||
|
||||
### TeamSpeak 6
|
||||
- Self-hosted by design, free up to 32 slots
|
||||
- Granular Power/Needed Power permission system
|
||||
- Low resource client
|
||||
- Plugin ecosystem
|
||||
- Virtual servers
|
||||
- Whisper / poke / channel commander
|
||||
|
||||
### Fluxer
|
||||
- Fully open-source AGPL-3, Docker Compose deploy
|
||||
- No paywalls / license keys
|
||||
- Planned federation
|
||||
- Multi-backend switching
|
||||
- Erlang/OTP gateway for scale
|
||||
- 34 locales
|
||||
### Discord / Guilded / TeamSpeak / Fluxer
|
||||
See historical notes in git history if needed. Not the product roadmap.
|
||||
|
||||
---
|
||||
|
||||
@@ -210,38 +184,37 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
||||
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||
| Self-hostable | ✅ | ❌ | ❌ | ✅ | ✅ |
|
||||
| Open source | ❌ | ❌ | ❌ | ❌ | ✅ AGPL-3 |
|
||||
| Open source | ⚠️ private self-host | ❌ | ❌ | ❌ | ✅ AGPL-3 |
|
||||
| PWA support | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| REST API | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| WebSocket gateway | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Swagger docs | ✅ localhost | ✅ | ✅ | ❌ | ✅ |
|
||||
| Swagger docs | ✅ /docs | ✅ | ✅ | ❌ | ✅ |
|
||||
| Docker Compose | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Priority Recommendations
|
||||
## Priority Recommendations (updated)
|
||||
|
||||
### Critical gaps (would block most Discord/Guilded users)
|
||||
**Do not treat this table as a todo list.** For a ~12 person friend server, parity rows are optional.
|
||||
|
||||
1. **Direct Messages** — no way to message users outside servers
|
||||
2. **Mobile app** — major adoption blocker
|
||||
3. **Voice push-to-talk** — important for voice-heavy communities
|
||||
4. **Email notifications** — needed for async engagement
|
||||
5. **Read receipts / unread state** — channel-level read tracking
|
||||
### Actual next polish (product)
|
||||
1. Mobile PWA pain (input, notifs, safe areas)
|
||||
2. Role color UI / hierarchy polish
|
||||
3. Voice PTT / screen share only if voice is used
|
||||
4. Small slash toys (`/roll`, `/choose`) if wanted
|
||||
|
||||
### High-value next features
|
||||
### Real tech debt
|
||||
1. More tests on hot paths (permissions DB checker, bot auth)
|
||||
2. Keep client permission cache in sync after role edits (partially done)
|
||||
3. Deploy docs must name the real unit: `dumpster.service`
|
||||
|
||||
1. **Screen share** — LiveKit supports it; mostly frontend work
|
||||
2. **Custom emoji / reactions beyond unicode** — core Discord behavior
|
||||
3. **Server groups (sub-servers)** — channel organization
|
||||
4. **Do Not Disturb schedule** — notification control
|
||||
5. **Third-party integrations** — Twitch, YouTube, GitHub
|
||||
6. **Stage channels** — presentation-style voice
|
||||
### Explicitly not critical
|
||||
- Friend requests (DMs already exist among members)
|
||||
- Native mobile apps
|
||||
- Discord bot ecosystem compatibility
|
||||
- Server discovery / monetization / federation
|
||||
- AutoMod / Flow Bots
|
||||
|
||||
### Nice-to-have differentiators
|
||||
|
||||
1. **No-code Flow Bots** (Guilded-style automations)
|
||||
2. **Server discovery / directory**
|
||||
3. **Activities / embedded games**
|
||||
4. **Federation** (Fluxer-style)
|
||||
5. **Server analytics**
|
||||
### Stale claims removed
|
||||
- ~~"Direct Messages missing"~~ — DMs exist
|
||||
- ~~"Mobile app is the only path"~~ — PWA is the target client
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
SHELL := /bin/bash
|
||||
.PHONY: build build-web build-server build-tui run run-tui dev docker-up docker-down clean
|
||||
|
||||
GIT_SHA := $(shell git rev-parse --short HEAD)
|
||||
|
||||
# Build everything
|
||||
build: build-web build-server
|
||||
|
||||
# Build the Go server
|
||||
build-server:
|
||||
CGO_ENABLED=0 go build -o dumpster-server ./cmd/server
|
||||
CGO_ENABLED=0 go build -ldflags "-X main.GitSHA=$(GIT_SHA)" -o dumpster-server ./cmd/server
|
||||
|
||||
# Build the TUI client
|
||||
build-tui:
|
||||
CGO_ENABLED=0 go build -o dumpster-tui ./cmd/tui
|
||||
|
||||
# Build the Tauri desktop app (Linux AppImage)
|
||||
build-tauri:
|
||||
cd web && NODE_ENV=development NO_STRIP=true npx tauri build --bundles appimage,deb,rpm
|
||||
|
||||
# Build signed Android APK
|
||||
build-android:
|
||||
cd web && ANDROID_HOME=$(HOME)/Android/Sdk NDK_HOME=$(HOME)/Android/Sdk/ndk/27.2.12479018 JAVA_HOME=/usr/lib/jvm/java-17-openjdk npx tauri android build --apk
|
||||
@echo "APK: web/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
||||
|
||||
# Build signed Android AAB (Google Play bundle)
|
||||
build-android-aab:
|
||||
cd web && ANDROID_HOME=$(HOME)/Android/Sdk NDK_HOME=$(HOME)/Android/Sdk/ndk/27.2.12479018 JAVA_HOME=/usr/lib/jvm/java-17-openjdk npx tauri android build --aab
|
||||
@echo "AAB: web/src-tauri/gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab"
|
||||
|
||||
# Generate Swagger docs
|
||||
docs:
|
||||
~/go/bin/swag init -g cmd/server/main.go -o docs
|
||||
|
||||
# Build the web frontend
|
||||
build-web:
|
||||
cd web && ([ -s ~/.nvm/nvm.sh ] && . ~/.nvm/nvm.sh && npm run build || npm run build)
|
||||
cd web && ([ -s ~/.nvm/nvm.sh ] && . ~/.nvm/nvm.sh && GIT_SHA=$(GIT_SHA) npm run build || GIT_SHA=$(GIT_SHA) npm run build)
|
||||
|
||||
# Run the server locally (dev mode)
|
||||
run: build-server
|
||||
|
||||
@@ -58,10 +58,13 @@ For optional features, copy `.env.example` to `.env` and set Giphy, MinIO, LiveK
|
||||
- [x] Blocks (user-level blocking)
|
||||
- [x] Dark/light mode toggle
|
||||
- [x] Mobile-responsive layout (bottom nav, drawer)
|
||||
- [x] Bot framework (token auth, CRUD, WebSocket gateway)
|
||||
- [x] Bot framework (token auth, CRUD, WebSocket gateway, bot store)
|
||||
- [x] Built-in bot runner (server-managed bots, no SSH needed)
|
||||
- [x] Built-in bot: Steam Free Games (polls Steam API, auto-posts)
|
||||
- [x] Bot message badges (green name + BOT tag in chat)
|
||||
- [x] Slash commands (registration, autocomplete)
|
||||
- [x] Incoming webhooks (create, execute)
|
||||
- [x] Example bots (modbot, welcome bot)
|
||||
- [x] Example bots (modbot, welcome bot, steamfree)
|
||||
- [x] TUI client (Bubbletea, vim-style, voice support)
|
||||
- [x] Roles & permissions system
|
||||
- [x] Push notification backend (VAPID)
|
||||
@@ -71,6 +74,9 @@ For optional features, copy `.env.example` to `.env` and set Giphy, MinIO, LiveK
|
||||
- [x] WebAuthn backend (full begin/finish flow)
|
||||
- [x] REST API documentation (Swagger UI at /docs)
|
||||
- [x] Push notification mention parsing and dispatch
|
||||
- [x] Inline markdown formatting toolbar (B/I/S/code/spoiler)
|
||||
- [x] GIF picker and kaomoji in DMs
|
||||
- [x] Restore last active channel on login
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -94,8 +100,9 @@ dumpsterChat/
|
||||
│ ├── voice/ # LiveKit voice/video integration
|
||||
│ ├── reaction/ # Message reactions
|
||||
│ ├── invite/ # Server invite links
|
||||
│ ├── bot/ # Bot framework, auth, commands
|
||||
│ ├── bot/ # Bot framework, auth, commands, runner
|
||||
│ ├── webhook/ # Incoming webhooks
|
||||
│ ├── dm/ # Direct messages (conversations)
|
||||
│ ├── push/ # Push notification sender (VAPID)
|
||||
│ ├── permissions/ # Permission bitflags and checker
|
||||
│ ├── moderation/ # Mute, ban, kick, slowmode, audit log
|
||||
@@ -108,7 +115,8 @@ dumpsterChat/
|
||||
│ └── block/ # User blocking
|
||||
├── examples/ # Example bots
|
||||
│ ├── modbot/ # Moderation bot
|
||||
│ └── welcome/ # Welcome message bot
|
||||
│ ├── welcome/ # Welcome message bot
|
||||
│ └── steamfree/ # Steam free games bot (standalone)
|
||||
├── web/ # React frontend (PWA)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Layout, ChatArea, LoginForm, UserSettings, GiphyPicker, VoiceChannel, VoicePanel, VoiceControls, TypingIndicator, ReactionBar, EmojiPicker, ReplyBar, MentionPopup, InviteModal, JoinServer, MobileNav, MobileDrawer, ThemeToggle, InstallPrompt, BotManager, CommandManager, SlashCommandPopup
|
||||
@@ -133,11 +141,50 @@ See `.env.example` for the full list. Key variables:
|
||||
| `LIVEKIT_*` | Voice/video server | No |
|
||||
| `GIPHY_API_KEY` | Giphy API key for GIF search | No |
|
||||
| `MINIO_*` | File upload storage | No |
|
||||
| `VAPID_PUBLIC_KEY` | Web push public key | No (generate with `go run ./cmd/keygen`) |
|
||||
| `VAPID_PRIVATE_KEY` | Web push private key | No |
|
||||
| `VAPID_SUBJECT` | Contact for VAPID JWT (e.g. `admin@your.domain`) | No |
|
||||
|
||||
### Push Notifications
|
||||
|
||||
Web push uses VAPID keys. Generate a keypair:
|
||||
|
||||
```bash
|
||||
go run ./cmd/keygen
|
||||
```
|
||||
|
||||
Copy the output into your `.env`. Set `VAPID_SUBJECT` to your email address **without** the `mailto:` prefix (the library adds it automatically).
|
||||
|
||||
Users are prompted to enable notifications on login. On iOS, the prompt requires a tap gesture, so a banner appears instead of a silent auto-subscribe. On Android, notifications deliver through FCM when Chrome is running (even in background). Ensure Chrome battery optimization is set to Unrestricted for reliable delivery when the PWA is closed.
|
||||
|
||||
## Wiki
|
||||
|
||||
Full documentation: [dumpsterChat wiki](ssh://git@git.dustin.coffee:2222/hobokenchicken/dumpsterChat.wiki.git)
|
||||
|
||||
## Production Deploy (SBC / 172.20.0.125)
|
||||
|
||||
App lives at `/opt/dumpsterChat`. systemd unit name is **`dumpster.service`** (not `dumpsterChat`).
|
||||
|
||||
```bash
|
||||
# Build locally
|
||||
CGO_ENABLED=0 go build -o dumpster-server ./cmd/server
|
||||
(cd web && npm run build)
|
||||
|
||||
# Ship binary + web assets
|
||||
scp dumpster-server root@172.20.0.125:/tmp/dumpster-server-new
|
||||
rsync -av --delete web/dist/ root@172.20.0.125:/opt/dumpsterChat/web/dist/
|
||||
|
||||
ssh root@172.20.0.125 '
|
||||
install -m 755 /tmp/dumpster-server-new /opt/dumpsterChat/dumpster-server
|
||||
systemctl restart dumpster
|
||||
systemctl is-active dumpster
|
||||
'
|
||||
```
|
||||
|
||||
Logs: `journalctl -u dumpster -f`
|
||||
Health: `curl -s http://127.0.0.1:8080/` (or your API health route)
|
||||
Public: Caddy → `dumpster.dustin.coffee` → `172.20.0.125:8080`
|
||||
|
||||
## License
|
||||
|
||||
AGPLv3
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
priv, pub, err := webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("VAPID_PRIVATE_KEY=" + priv)
|
||||
fmt.Println("VAPID_PUBLIC_KEY=" + pub)
|
||||
}
|
||||
@@ -103,6 +103,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
edited_at TIMESTAMPTZ,
|
||||
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -126,6 +127,8 @@ CREATE TABLE IF NOT EXISTS member_roles (
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id);
|
||||
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
`
|
||||
|
||||
const downSQL = `
|
||||
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -16,6 +18,7 @@ import (
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/dm"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/email"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
|
||||
@@ -34,6 +37,7 @@ import (
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook"
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
httpSwagger "github.com/swaggo/http-swagger"
|
||||
)
|
||||
|
||||
@@ -43,9 +47,8 @@ import (
|
||||
// @host localhost:8080
|
||||
// @BasePath /api/v1
|
||||
// @schemes http https
|
||||
// @securityDefinitions.apikey SessionAuth
|
||||
// @in cookie
|
||||
// @name dumpster_session
|
||||
var GitSHA = "dev"
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
|
||||
@@ -67,7 +70,7 @@ func main() {
|
||||
sessionStore := auth.NewSessionStore(database.DB, cfg)
|
||||
|
||||
// WebSocket origin allowlist
|
||||
wsOrigins := []string{"https://" + cfg.Host}
|
||||
wsOrigins := []string{"https://" + cfg.Host, "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost"}
|
||||
if cfg.Host == "localhost" {
|
||||
wsOrigins = append(wsOrigins, "http://localhost:"+cfg.Port)
|
||||
}
|
||||
@@ -77,6 +80,13 @@ func main() {
|
||||
hub := gateway.NewHub(database.DB, logger)
|
||||
go hub.Run()
|
||||
|
||||
// Built-in bot runner
|
||||
botRunner := bot.NewRunner(database.DB, hub, logger)
|
||||
botRunner.Register("steamfree", bot.SteamFreeBot)
|
||||
botRunner.Register("confess", bot.ConfessBot)
|
||||
botRunner.Register("leaderboard", bot.LeaderboardBot)
|
||||
go botRunner.StartAll()
|
||||
|
||||
// Giphy client (nil if no API key)
|
||||
giphyClient := giphy.NewClient(cfg.Giphy.APIKey)
|
||||
|
||||
@@ -98,14 +108,27 @@ func main() {
|
||||
logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)")
|
||||
}
|
||||
|
||||
// Email mailer
|
||||
mailer := email.NewMailer(cfg.SMTP)
|
||||
|
||||
// Auth handler
|
||||
authHandler := auth.NewHandler(database.DB, cfg, hub)
|
||||
authHandler := auth.NewHandler(database.DB, cfg, hub, mailer)
|
||||
|
||||
// Permissions checker
|
||||
permissionsChecker := permissions.NewChecker(database.DB)
|
||||
memberHandler := server.NewMemberHandler(database.DB)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"https://" + cfg.Host, "http://localhost:" + cfg.Port, "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link", "X-Session-Token"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(chimw.RequestID)
|
||||
@@ -122,8 +145,21 @@ func main() {
|
||||
gateway.ServeWS(database.DB, hub, logger, w, r, cfg.Session.CookieName)
|
||||
})
|
||||
|
||||
// Bot WebSocket endpoint (auth via ?token= query param)
|
||||
r.Get("/ws/bot", func(w http.ResponseWriter, r *http.Request) {
|
||||
gateway.ServeBotWS(database.DB, hub, logger, w, r)
|
||||
})
|
||||
|
||||
// API routes
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"version": GitSHA,
|
||||
})
|
||||
})
|
||||
|
||||
// Auth (public: register, login, logout) with strict rate limiting
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10
|
||||
@@ -134,7 +170,10 @@ func main() {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Session(sessionStore, cfg))
|
||||
r.Use(middleware.RequireAuth)
|
||||
r.Use(middleware.CSRFProtect(cfg.Host, cfg.Port, strings.Split(os.Getenv("DUMPSTER_CSRF_ORIGINS"), ",")))
|
||||
r.Use(middleware.CSRFProtect(cfg.Host, cfg.Port, append(
|
||||
strings.Split(os.Getenv("DUMPSTER_CSRF_ORIGINS"), ","),
|
||||
"tauri://localhost", "http://tauri.localhost", "https://tauri.localhost",
|
||||
)))
|
||||
|
||||
// Auth (protected: me, update profile)
|
||||
authHandler.RegisterProtectedRoutes(r)
|
||||
@@ -206,7 +245,7 @@ func main() {
|
||||
})
|
||||
|
||||
// Direct messages
|
||||
dmHandler := dm.NewHandler(database.DB, hub, logger)
|
||||
dmHandler := dm.NewHandler(database.DB, hub, pushHandler, logger)
|
||||
r.Route("/conversations", func(r chi.Router) {
|
||||
dmHandler.RegisterRoutes(r)
|
||||
})
|
||||
@@ -217,7 +256,21 @@ func main() {
|
||||
|
||||
// Messages (under channels)
|
||||
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
||||
message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker).RegisterRoutes(r)
|
||||
msgHandler := message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker)
|
||||
msgHandler.SetConfessHandler(botRunner)
|
||||
msgHandler.RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Polls
|
||||
pollHandler := message.NewPollHandler(database.DB, hub, permissionsChecker)
|
||||
r.Route("/polls", func(r chi.Router) {
|
||||
pollHandler.RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Feature requests
|
||||
frHandler := message.NewFeatureRequestHandler(database.DB)
|
||||
r.Route("/servers/{serverID}/feature-requests", func(r chi.Router) {
|
||||
frHandler.RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Per-channel notification settings
|
||||
@@ -225,6 +278,29 @@ func main() {
|
||||
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Calendar events
|
||||
calHandler := channel.NewHandler(database.DB, permissionsChecker)
|
||||
r.Route("/channels/{channelID}/events", func(r chi.Router) {
|
||||
r.Get("/", calHandler.ListEvents)
|
||||
r.Post("/", calHandler.CreateEvent)
|
||||
})
|
||||
|
||||
// Threads (forum posts)
|
||||
threadHandler := channel.NewHandler(database.DB, permissionsChecker)
|
||||
r.Route("/channels/{channelID}/threads", func(r chi.Router) {
|
||||
r.Get("/", threadHandler.ListThreads)
|
||||
r.Post("/", threadHandler.CreateThread)
|
||||
})
|
||||
r.Patch("/threads/{threadID}", threadHandler.UpdateThread)
|
||||
|
||||
// Forum tags
|
||||
r.Get("/channels/{channelID}/forum-tags", threadHandler.ListForumTags)
|
||||
r.Post("/channels/{channelID}/forum-tags", threadHandler.CreateForumTag)
|
||||
r.Delete("/forum-tags/{tagID}", threadHandler.DeleteForumTag)
|
||||
|
||||
// Push notifications
|
||||
pushHandler.RegisterRoutes(r)
|
||||
|
||||
// Read receipts
|
||||
rsHandler := readstate.NewHandler(database.DB, logger)
|
||||
r.Put("/channels/{channelID}/read", rsHandler.MarkRead)
|
||||
@@ -255,6 +331,34 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(gifs)
|
||||
})
|
||||
r.Get("/gifs/proxy", func(w http.ResponseWriter, r *http.Request) {
|
||||
targetURL := r.URL.Query().Get("url")
|
||||
if targetURL == "" {
|
||||
http.Error(w, `{"error":"missing url"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify it's a Giphy domain to prevent general proxy abuse
|
||||
parsed, err := url.Parse(targetURL)
|
||||
if err != nil || parsed.Scheme != "https" || !strings.HasSuffix(parsed.Host, ".giphy.com") {
|
||||
http.Error(w, `{"error":"invalid proxy target"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.Get(targetURL)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to fetch image"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Stream response
|
||||
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
|
||||
w.Header().Set("Content-Length", resp.Header.Get("Content-Length"))
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800") // Cache for 7 days
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
})
|
||||
}
|
||||
|
||||
// File upload
|
||||
@@ -274,13 +378,9 @@ func main() {
|
||||
reaction.NewHandler(database.DB, hub).RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Bots
|
||||
// Bots + slash commands
|
||||
r.Route("/bots", func(r chi.Router) {
|
||||
bot.NewHandler(database.DB).RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Bot slash commands
|
||||
r.Route("/bots/{botID}/commands", func(r chi.Router) {
|
||||
bot.NewHandler(database.DB, botRunner).RegisterRoutes(r)
|
||||
bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r)
|
||||
})
|
||||
|
||||
@@ -305,6 +405,12 @@ func main() {
|
||||
// File serving (public, for viewing uploaded files)
|
||||
if uploadHandler != nil {
|
||||
r.Get("/files/*", uploadHandler.Serve)
|
||||
|
||||
// Legacy redirect: old uploads returned /{bucket}/objectName
|
||||
r.Get("/dumpster-files/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
objectName := strings.TrimPrefix(r.URL.Path, "/dumpster-files/")
|
||||
http.Redirect(w, r, "/files/"+objectName, http.StatusMovedPermanently)
|
||||
})
|
||||
}
|
||||
|
||||
// Public webhook execution (no auth required)
|
||||
@@ -332,17 +438,28 @@ func main() {
|
||||
))
|
||||
})
|
||||
|
||||
// Privacy policy page (served before SPA catch-all)
|
||||
r.HandleFunc("/privacy", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write([]byte(privacyPage))
|
||||
})
|
||||
|
||||
// Static file serving for production (SPA)
|
||||
staticDir := "/srv/web"
|
||||
staticDir := "web/dist"
|
||||
if _, err := os.Stat(staticDir); err == nil {
|
||||
fileServer := http.FileServer(http.Dir(staticDir))
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
// If the file exists, serve it; otherwise serve index.html (SPA fallback)
|
||||
path := staticDir + r.URL.Path
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
http.ServeFile(w, r, staticDir+"/index.html")
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -354,3 +471,104 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const privacyPage = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Privacy Policy — dumpsterChat</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: #1d2021; color: #ebdbb2; line-height: 1.7; padding: 2rem 1rem;
|
||||
}
|
||||
main { max-width: 720px; margin: 0 auto; }
|
||||
h1 { font-size: 1.8rem; margin-bottom: 0.25rem; color: #fabd2f; }
|
||||
.subtitle { color: #a89984; font-size: 0.85rem; margin-bottom: 2rem; }
|
||||
h2 { font-size: 1.15rem; margin: 1.5rem 0 0.5rem; color: #83a598; }
|
||||
p, li { margin-bottom: 0.6rem; }
|
||||
ul { padding-left: 1.25rem; }
|
||||
li { margin-bottom: 0.3rem; }
|
||||
a { color: #8ec07c; }
|
||||
.footer { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #3c3836; font-size: 0.8rem; color: #928374; }
|
||||
.update { color: #928374; font-size: 0.8rem; margin-top: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p class="subtitle"><strong>dumpsterChat</strong> — Last updated: July 17, 2026</p>
|
||||
|
||||
<h2>Overview</h2>
|
||||
<p>dumpsterChat is a self-hosted messaging platform. This privacy policy describes how your data is handled when you use the app. Because dumpsterChat is <strong>self-hosted</strong>, your data is stored on the server instance you connect to, which is operated by the server owner — not by us.</p>
|
||||
|
||||
<h2>Data We Collect</h2>
|
||||
<p>When you use dumpsterChat, the following data is stored on the server:</p>
|
||||
<ul>
|
||||
<li><strong>Account information:</strong> username, email address, avatar, and password hash (Argon2id, not reversible).</li>
|
||||
<li><strong>Messages and content:</strong> text messages, reactions, uploaded files, voice activity metadata, and poll votes.</li>
|
||||
<li><strong>Session data:</strong> login sessions stored in encrypted cookies.</li>
|
||||
</ul>
|
||||
|
||||
<h2>How We Use Your Data</h2>
|
||||
<p>Your data is used solely to operate the chat platform:</p>
|
||||
<ul>
|
||||
<li>Deliver messages and notifications to the intended recipients.</li>
|
||||
<li>Sync read states and presence (online/offline) across your devices.</li>
|
||||
<li>Provide moderation tools (kicks, bans, mutes) per server rules.</li>
|
||||
</ul>
|
||||
|
||||
<h2>No Third-Party Analytics</h2>
|
||||
<p>dumpsterChat does <strong>not</strong> include any analytics SDKs, tracking pixels, or telemetry. No usage data is sent to us or to any third party for advertising, profiling, or analytics purposes.</p>
|
||||
|
||||
<h2>Third-Party Integrations</h2>
|
||||
<p>If enabled by the server owner, optional integrations may be used:</p>
|
||||
<ul>
|
||||
<li><strong>Giphy:</strong> GIF search queries are proxied through the server to Giphy's API. No user data is shared with Giphy.</li>
|
||||
<li><strong>LiveKit:</strong> Voice and video calls use a self-hosted LiveKit server. Media streams are processed in real time and are not recorded or stored by default.</li>
|
||||
</ul>
|
||||
<p>These integrations are optional and controlled entirely by the server owner.</p>
|
||||
|
||||
<h2>Data Retention</h2>
|
||||
<p>Data is retained for as long as the server owner maintains the database. You can delete your messages or account at any time through the app. Server owners may also set message retention limits. Uploaded files persist until explicitly removed.</p>
|
||||
|
||||
<h2>Your Rights</h2>
|
||||
<p>Depending on your jurisdiction, you may have the right to:</p>
|
||||
<ul>
|
||||
<li>Request a copy of your stored data.</li>
|
||||
<li>Delete your account and associated data.</li>
|
||||
<li>Correct inaccurate personal information.</li>
|
||||
</ul>
|
||||
<p>To exercise these rights, contact the operator of the dumpsterChat instance you use, or use the account management tools available within the app.</p>
|
||||
|
||||
<h2>Account Deletion Requests</h2>
|
||||
<p>To request deletion of your account and all associated data:</p>
|
||||
<ul>
|
||||
<li>Use the <strong>Delete Account</strong> option in your account settings within the app.</li>
|
||||
<li>Or email <a href="mailto:account-deletion@dustin.coffee">account-deletion@dustin.coffee</a> from the email address associated with your account.</li>
|
||||
</ul>
|
||||
<p>We will process your request within 30 days. Deletion removes your account, messages, uploaded files, and all personal data from the server.</p>
|
||||
|
||||
<h2>Security</h2>
|
||||
<p>We take reasonable measures to protect your data:</p>
|
||||
<ul>
|
||||
<li>Passwords are hashed with Argon2id.</li>
|
||||
<li>Session tokens use httpOnly cookies.</li>
|
||||
<li>All communications are encrypted over HTTPS and WSS where available.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Children's Privacy</h2>
|
||||
<p>dumpsterChat is not directed at children under 13. We do not knowingly collect personal information from children.</p>
|
||||
|
||||
<h2>Changes to This Policy</h2>
|
||||
<p>We may update this privacy policy from time to time. Changes will be posted at this URL. Continued use of the app after changes constitutes acceptance of the updated policy.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>If you have questions about this privacy policy, contact the operator of the dumpsterChat instance you use, or open an issue on our project repository.</p>
|
||||
|
||||
<div class="update">This privacy policy applies to the dumpsterChat mobile app and web app. The specific data practices of each instance may vary based on the server operator's configuration.</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
@@ -8,8 +8,37 @@ export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
nvm use 22
|
||||
|
||||
# Snapshot the current binary before pulling
|
||||
if [ -f dumpster-server ]; then
|
||||
cp dumpster-server dumpster-server.bak
|
||||
echo "Backed up existing binary to dumpster-server.bak"
|
||||
fi
|
||||
|
||||
git pull
|
||||
make build
|
||||
systemctl restart dumpster
|
||||
|
||||
echo "Deploy complete — $(date)"
|
||||
# Sync web dist to Docker Caddy volume if present
|
||||
if [ -d /var/lib/docker/volumes/docker_web_assets/_data ]; then
|
||||
rsync -a --delete web/dist/ /var/lib/docker/volumes/docker_web_assets/_data/
|
||||
echo "Synced web assets to Docker Caddy volume"
|
||||
fi
|
||||
|
||||
# Health check after restart — roll back on failure
|
||||
systemctl restart dumpster
|
||||
echo "Waiting for app to become healthy..."
|
||||
for i in $(seq 1 15); do
|
||||
if curl -sf http://localhost:8080/ > /dev/null 2>&1; then
|
||||
echo "Server is listening — deploy complete"
|
||||
rm -f dumpster-server.bak
|
||||
echo "Deploy complete — $(date)"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Health check failed after 30s — rolling back..."
|
||||
systemctl stop dumpster
|
||||
cp dumpster-server.bak dumpster-server
|
||||
systemctl start dumpster
|
||||
echo "Rolled back to previous binary — $(date)"
|
||||
exit 1
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
auto_https off
|
||||
https_port 8443
|
||||
}
|
||||
|
||||
:80 {
|
||||
# API routes
|
||||
handle_path /api/* {
|
||||
reverse_proxy app:8080
|
||||
# API routes — use handle (not handle_path) to preserve the /api/v1 prefix
|
||||
handle /api/* {
|
||||
reverse_proxy 172.18.0.1:8080
|
||||
}
|
||||
|
||||
# WebSocket gateway
|
||||
handle /ws {
|
||||
reverse_proxy app:8080 {
|
||||
reverse_proxy 172.18.0.1:8080 {
|
||||
header_up Connection {>Connection}
|
||||
header_up Upgrade {>Upgrade}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
FROM golang:1.24-alpine AS go-builder
|
||||
FROM golang:1.26.4-alpine AS go-builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd/ cmd/
|
||||
COPY internal/ internal/
|
||||
COPY docs/ docs/
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/dumpster-server ./cmd/server
|
||||
|
||||
FROM node:22-alpine AS web-builder
|
||||
@@ -15,8 +16,10 @@ RUN npm run build
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates
|
||||
RUN adduser -D -g '' appuser
|
||||
WORKDIR /app
|
||||
COPY --from=go-builder /bin/dumpster-server /app/dumpster-server
|
||||
COPY --from=web-builder /app/dist /srv/web
|
||||
USER appuser
|
||||
EXPOSE 8080
|
||||
CMD ["sh", "-c", "if [ -z \"$DUMPSTER_SECRET\" ]; then export DUMPSTER_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32); fi; /app/dumpster-server"]
|
||||
CMD sh -c "if [ -z \"$DUMPSTER_SECRET\" ]; then export DUMPSTER_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32); fi; /app/dumpster-server"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
version: '3.8'
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
backend:
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
@@ -32,6 +36,9 @@ services:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
@@ -49,6 +56,8 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- backend
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8
|
||||
@@ -62,6 +71,8 @@ services:
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- backend
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
@@ -75,6 +86,8 @@ services:
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
networks:
|
||||
- backend
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
@@ -88,31 +101,39 @@ services:
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
environment:
|
||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}"
|
||||
networks:
|
||||
- backend
|
||||
|
||||
coturn:
|
||||
image: coturn/coturn:latest
|
||||
container_name: dumpster-coturn
|
||||
command:
|
||||
- "-n"
|
||||
- "--log-file=stdout"
|
||||
- "--min-port=10000"
|
||||
- "--max-port=20000"
|
||||
- "--min-port=50101"
|
||||
- "--max-port=50200"
|
||||
- "--realm=${DUMPSTER_HOST:-localhost}"
|
||||
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
||||
- "--cert=/etc/letsencrypt/live/turn.dustin.coffee/fullchain.pem"
|
||||
- "--pkey=/etc/letsencrypt/live/turn.dustin.coffee/privkey.pem"
|
||||
- "--tls-listening-port=5349"
|
||||
ports:
|
||||
- "3478:3478/tcp"
|
||||
- "3478:3478/udp"
|
||||
- "5349:5349/tcp"
|
||||
- "5349:5349/udp"
|
||||
- "10000-20000:10000-20000/udp"
|
||||
- "50101-50200:50101-50200/udp"
|
||||
volumes:
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
networks:
|
||||
- backend
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
container_name: dumpster-caddy
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8443:8443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- web_assets:/srv/web:ro
|
||||
@@ -120,6 +141,8 @@ services:
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
port: 7880
|
||||
bind: 0.0.0.0
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
use_external_ip: true
|
||||
relay_range_start: 50000
|
||||
relay_range_end: 50100
|
||||
port_range_start: 50000
|
||||
port_range_end: 50100
|
||||
turn_servers:
|
||||
- host: turn.dustin.coffee
|
||||
port: 443
|
||||
protocol: tls
|
||||
username: "${LIVEKIT_API_KEY}"
|
||||
credential: "${LIVEKIT_API_SECRET}"
|
||||
turn:
|
||||
enabled: true
|
||||
domain: livekit.localhost
|
||||
tls_port: 5349
|
||||
udp_port: 3478
|
||||
external_tls: false
|
||||
enabled: false
|
||||
keys:
|
||||
devkey: secret
|
||||
"${LIVEKIT_API_KEY}": "${LIVEKIT_API_SECRET}"
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
# Capacitor Android App — Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Wrap the existing dumpsterChat Vite/React PWA in a Capacitor shell and publish to Google Play.
|
||||
|
||||
**Architecture:** Capacitor loads the Vite build output as local assets in an Android WebView. `@capacitor/core` bridges native APIs (push, status bar, etc.). No UI rewrite — the web app IS the app.
|
||||
|
||||
**Tech Stack:** Vite, React 18, Capacitor 6, FCM (push), Gradle (Android build)
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Capacitor Init
|
||||
|
||||
#### Task 1: Add Capacitor dependencies
|
||||
|
||||
**Objective:** Install Capacitor core + CLI in the web project.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/package.json`
|
||||
|
||||
**Steps:**
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install @capacitor/core @capacitor/cli @capacitor/android
|
||||
```
|
||||
|
||||
Then init Capacitor:
|
||||
|
||||
```bash
|
||||
npx cap init "Dumpster Chat" "coffee.dustin.dumpster" --web-dir dist
|
||||
```
|
||||
|
||||
This creates `capacitor.config.ts` at the web root.
|
||||
|
||||
**Verify:** `cat capacitor.config.ts` shows appId `coffee.dustin.dumpster`, webDir `dist`.
|
||||
|
||||
---
|
||||
|
||||
#### Task 2: Configure capacitor.config.ts
|
||||
|
||||
**Objective:** Set server URL for dev, configure Android-specific settings.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/capacitor.config.ts`
|
||||
|
||||
**Content:**
|
||||
|
||||
```ts
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'coffee.dustin.dumpster',
|
||||
appName: 'Dumpster Chat',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
// ponytail: no server.url — serve local assets. API calls go to absolute URL from api.ts.
|
||||
androidScheme: 'https', // cookies work over https scheme in WebView
|
||||
},
|
||||
plugins: {
|
||||
PushNotifications: {
|
||||
presentationOptions: ['badge', 'sound', 'alert'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
**Key decisions:**
|
||||
- `androidScheme: 'https'` makes `credentials: 'include'` cookies work in the WebView (http scheme blocks them).
|
||||
- No `server.url` — local assets load from the APK, not from the web. Faster, works offline.
|
||||
|
||||
---
|
||||
|
||||
#### Task 3: Add Android platform
|
||||
|
||||
**Objective:** Generate the native Android project.
|
||||
|
||||
**Files:**
|
||||
- Create: `web/android/` (generated by Capacitor)
|
||||
|
||||
**Steps:**
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npx cap add android
|
||||
```
|
||||
|
||||
**Verify:** `ls web/android/app/src/main/AndroidManifest.xml` exists.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: API Client Fix
|
||||
|
||||
#### Task 4: Update API base URL for native
|
||||
|
||||
**Objective:** When running in Capacitor, API calls need an absolute URL (no origin in a WebView). Keep relative paths for web/PWA.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/lib/api.ts`
|
||||
|
||||
**Changes:**
|
||||
|
||||
```ts
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
// ponytail: single switch. native = absolute URL, web = relative (Caddy same-origin).
|
||||
const API_BASE = Capacitor.isNativePlatform()
|
||||
? 'https://dumpster.dustin.coffee/api/v1'
|
||||
: '/api/v1';
|
||||
```
|
||||
|
||||
The rest of the file stays unchanged. `Capacitor.isNativePlatform()` returns `false` in browsers and `true` in the Android WebView.
|
||||
|
||||
**Skipped:** `@capacitor/http` plugin. Not needed — `androidScheme: 'https'` + absolute URL + `credentials: 'include'` works. Add the HTTP plugin only if cookies break.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Push Notifications (FCM)
|
||||
|
||||
This is the only non-trivial part. VAPID web push does not work in Android WebViews. Need FCM.
|
||||
|
||||
#### Task 5: Create Firebase project
|
||||
|
||||
**Objective:** Set up FCM credentials for native push.
|
||||
|
||||
**Steps (manual, one-time):**
|
||||
1. Go to https://console.firebase.google.com
|
||||
2. Create project (or use existing) named `dumpster-chat`
|
||||
3. Add Android app with package name `coffee.dustin.dumpster`
|
||||
4. Download `google-services.json` → place in `web/android/app/google-services.json`
|
||||
5. In Firebase Console → Project Settings → Cloud Messaging → note the **Server Key** (legacy) or set up **Firebase Admin SDK** service account
|
||||
|
||||
**Verify:** `google-services.json` exists in `web/android/app/`.
|
||||
|
||||
---
|
||||
|
||||
#### Task 6: Add Capacitor Push Notifications plugin
|
||||
|
||||
**Objective:** Register for FCM token on Android, send it to the server.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/package.json` (install plugin)
|
||||
- Modify: `web/src/stores/push.ts` (add native branch)
|
||||
|
||||
**Install:**
|
||||
```bash
|
||||
cd web
|
||||
npm install @capacitor/push-notifications
|
||||
```
|
||||
|
||||
**Modify `push.ts`** — add a native registration path alongside the existing web push:
|
||||
|
||||
```ts
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
// Existing web push subscribe stays as-is for PWA.
|
||||
// Add native branch:
|
||||
async function subscribeNative() {
|
||||
const { PushNotifications } = await import('@capacitor/push-notifications');
|
||||
|
||||
const permStatus = await PushNotifications.requestPermissions();
|
||||
if (permStatus.receive !== 'granted') return;
|
||||
|
||||
await PushNotifications.register();
|
||||
|
||||
// Server sends us the FCM token via this event
|
||||
PushNotifications.addListener('registration', async (token) => {
|
||||
await api.post('/push/subscribe', {
|
||||
endpoint: 'fcm:' + token.value, // ponytail: prefix to distinguish from web push endpoints
|
||||
keys: { p256dh: '', auth: '' }, // not used for FCM, but server expects the shape
|
||||
});
|
||||
});
|
||||
|
||||
PushNotifications.addListener('pushNotificationReceived', (notification) => {
|
||||
// Foreground notification — show in-app toast or badge
|
||||
// ponytail: handled by existing in-app notification system
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Then in the existing `subscribe()` function, branch:
|
||||
|
||||
```ts
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
return subscribeNative();
|
||||
}
|
||||
// ... existing web push logic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 7: Server-side FCM send support
|
||||
|
||||
**Objective:** When a push subscription's endpoint starts with `fcm:`, send via FCM HTTP v1 API instead of VAPID.
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/push/handlers.go`
|
||||
|
||||
**Changes:**
|
||||
1. In `Subscribe()`: detect `fcm:` prefix on endpoint, store differently (or store as-is, the prefix distinguishes it).
|
||||
2. In the send functions (`Send`, `SendToUser`): check if subscription endpoint starts with `fcm:` → use Firebase Admin SDK to send.
|
||||
|
||||
**Install Go Firebase Admin:**
|
||||
```bash
|
||||
go get firebase.google.com/go/v4
|
||||
```
|
||||
|
||||
**Pattern:**
|
||||
```go
|
||||
// ponytail: one if/else in the send loop. endpoint prefix = routing key.
|
||||
if strings.HasPrefix(sub.Endpoint, "fcm:") {
|
||||
token := strings.TrimPrefix(sub.Endpoint, "fcm:")
|
||||
msg := &messaging.Message{
|
||||
Token: token,
|
||||
Notification: &messaging.Notification{
|
||||
Title: title,
|
||||
Body: body,
|
||||
},
|
||||
Data: map[string]string{"url": url},
|
||||
}
|
||||
_, err = fcmClient.Send(ctx, msg)
|
||||
} else {
|
||||
// existing VAPID webpush send
|
||||
}
|
||||
```
|
||||
|
||||
**Config:** Add `FIREBASE_CREDENTIALS_FILE` env var (path to service account JSON) to the systemd unit / Docker compose.
|
||||
|
||||
**Skipped:** Topic-based broadcast. Per-device tokens is fine for now. Add topics when channel count grows.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Gradle / Build Config
|
||||
|
||||
#### Task 8: Configure Android build
|
||||
|
||||
**Objective:** Set minimum SDK, app icon, theme.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/android/app/build.gradle`
|
||||
- Modify: `web/android/app/src/main/res/values/strings.xml`
|
||||
|
||||
**Changes in `build.gradle`:**
|
||||
```gradle
|
||||
minSdkVersion = 24 // ponytail: Android 7+ covers 99% of Play Store. lower = more compat bugs.
|
||||
```
|
||||
|
||||
**App name in `strings.xml`:**
|
||||
```xml
|
||||
<string name="app_name">Dumpster Chat</string>
|
||||
```
|
||||
|
||||
**App icon:** Copy existing PWA icons into Android mipmap directories:
|
||||
```bash
|
||||
# Capacitor can sync icons automatically if placed at web/public/icon.png (1024x1024)
|
||||
# or manually: web/android/app/src/main/res/mipmap-*/
|
||||
npx cap assets generate # if a 1024x1024 source icon exists
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Build & Publish
|
||||
|
||||
#### Task 9: Sync and build debug APK
|
||||
|
||||
**Objective:** Verify the app runs on a real device or emulator.
|
||||
|
||||
**Steps:**
|
||||
```bash
|
||||
cd web
|
||||
npm run build # builds Vite → dist/
|
||||
npx cap sync android # copies dist/ into android/assets, syncs plugins
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
**Output:** `web/android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
**Verify:** Install on Android device:
|
||||
```bash
|
||||
adb install app-debug.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 10: Build signed release AAB for Play Store
|
||||
|
||||
**Objective:** Create a signed Android App Bundle (.aab) for Google Play upload.
|
||||
|
||||
**Steps:**
|
||||
1. Generate keystore (one-time):
|
||||
```bash
|
||||
keytool -genkey -v -keystore dumpster-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias dumpster
|
||||
```
|
||||
Store `dumpster-release.jks` securely. Back it up. Lose it = can't update the app.
|
||||
|
||||
2. Add signing config to `web/android/app/build.gradle`:
|
||||
```gradle
|
||||
android {
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file('dumpster-release.jks')
|
||||
storePassword System.getenv('KEYSTORE_PASSWORD')
|
||||
keyAlias 'dumpster'
|
||||
keyPassword System.getenv('KEY_PASSWORD')
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Build AAB:
|
||||
```bash
|
||||
cd web/android
|
||||
KEYSTORE_PASSWORD=xxx KEY_PASSWORD=xxx ./gradlew bundleRelease
|
||||
```
|
||||
|
||||
**Output:** `web/android/app/build/outputs/bundle/release/app-release.aab`
|
||||
|
||||
4. Upload to Google Play Console → your new developer account → Create app → Upload AAB.
|
||||
|
||||
---
|
||||
|
||||
#### Task 11: Clean up Tauri dependencies
|
||||
|
||||
**Objective:** Remove unused Tauri packages (pivoted away from Tauri).
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/package.json`
|
||||
|
||||
**Steps:**
|
||||
```bash
|
||||
cd web
|
||||
npm uninstall @tauri-apps/api @tauri-apps/cli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
| Phase | What | Time estimate |
|
||||
|-------|------|---------------|
|
||||
| 1 | Capacitor init + Android platform | 10 min |
|
||||
| 2 | API base URL native branch | 5 min |
|
||||
| 3 | FCM push (plugin + server) | 1-2 hrs (incl. Firebase setup) |
|
||||
| 4 | Gradle config / icons | 15 min |
|
||||
| 5 | Build, test, publish | 30 min |
|
||||
|
||||
**Total:** ~2-3 hours end-to-end. Phase 3 is the only real work.
|
||||
|
||||
**Dependencies between tasks:**
|
||||
- Tasks 1-3 sequential (Capacitor init)
|
||||
- Task 4 independent of 5-7
|
||||
- Tasks 5-7 sequential (FCM chain)
|
||||
- Task 8 depends on 1-3
|
||||
- Task 9 depends on all above
|
||||
- Task 10 depends on 9
|
||||
- Task 11 independent, do anytime
|
||||
|
||||
**After this plan:** Update the Makefile `build` target to include `npm run build && npx cap sync android` so deploys sync native assets too.
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ---- config from env ----
|
||||
|
||||
var (
|
||||
botToken string
|
||||
host string
|
||||
channelID string
|
||||
pollMinutes int
|
||||
seen = map[string]bool{} // appid -> posted
|
||||
)
|
||||
|
||||
// ---- dumpsterChat WS protocol ----
|
||||
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// ---- Steam API response ----
|
||||
|
||||
type FeaturedCategories struct {
|
||||
Specials FeaturedList `json:"specials"`
|
||||
}
|
||||
|
||||
type FeaturedList struct {
|
||||
Items []FeaturedItem `json:"items"`
|
||||
}
|
||||
|
||||
type FeaturedItem struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DiscountPct int `json:"discount_percent"`
|
||||
FinalPrice int `json:"final_price"` // in cents
|
||||
OriginalPrice int `json:"original_price"` // in cents
|
||||
}
|
||||
|
||||
func main() {
|
||||
botToken = os.Getenv("BOT_TOKEN")
|
||||
if botToken == "" {
|
||||
log.Fatal("BOT_TOKEN required (create a bot in the dumpsterChat store)")
|
||||
}
|
||||
host = os.Getenv("DUMPSTER_HOST")
|
||||
if host == "" {
|
||||
host = "localhost:8080"
|
||||
}
|
||||
channelID = os.Getenv("CHANNEL_ID")
|
||||
if channelID == "" {
|
||||
log.Fatal("CHANNEL_ID required (the channel to post free games to)")
|
||||
}
|
||||
pollMinutes = 30
|
||||
if m := os.Getenv("POLL_MINUTES"); m != "" {
|
||||
if v, err := strconv.Atoi(m); err == nil && v > 0 {
|
||||
pollMinutes = v
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to dumpsterChat
|
||||
u := url.URL{
|
||||
Scheme: "ws",
|
||||
Host: host,
|
||||
Path: "/ws/bot",
|
||||
RawQuery: "token=" + botToken,
|
||||
}
|
||||
|
||||
log.Printf("SteamFree bot connecting to %s", u.String())
|
||||
|
||||
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
log.Fatal("dial:", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
log.Println("SteamFree bot connected!")
|
||||
|
||||
// First poll immediately, then on ticker
|
||||
poll(c)
|
||||
|
||||
ticker := time.NewTicker(time.Duration(pollMinutes) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Keep connection alive by reading (we don't need to react to events)
|
||||
go func() {
|
||||
for {
|
||||
_, _, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
log.Println("ws read:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for range ticker.C {
|
||||
poll(c)
|
||||
}
|
||||
}
|
||||
|
||||
func poll(c *websocket.Conn) {
|
||||
log.Println("Polling Steam for free games...")
|
||||
|
||||
games, err := fetchFreeGames()
|
||||
if err != nil {
|
||||
log.Println("fetch error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
newCount := 0
|
||||
for _, g := range games {
|
||||
if seen[strconv.Itoa(g.ID)] {
|
||||
continue
|
||||
}
|
||||
seen[strconv.Itoa(g.ID)] = true
|
||||
newCount++
|
||||
|
||||
msg := formatGame(g)
|
||||
sendMessage(c, channelID, msg)
|
||||
log.Printf("Posted: %s (was $%.2f, now FREE)", g.Name, float64(g.OriginalPrice)/100)
|
||||
time.Sleep(500 * time.Millisecond) // be polite to the WS
|
||||
}
|
||||
|
||||
if newCount == 0 {
|
||||
log.Println("No new free games found")
|
||||
} else {
|
||||
log.Printf("Posted %d new free games", newCount)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchFreeGames() ([]FeaturedItem, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get("https://store.steampowered.com/api/featuredcategories?cc=us&l=english")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("steam api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("steam api: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
var cats FeaturedCategories
|
||||
if err := json.Unmarshal(body, &cats); err != nil {
|
||||
return nil, fmt.Errorf("parse json: %w", err)
|
||||
}
|
||||
|
||||
// Filter: 100% discount and actually had a price (not permanent free-to-play)
|
||||
var free []FeaturedItem
|
||||
for _, item := range cats.Specials.Items {
|
||||
if item.DiscountPct == 100 && item.OriginalPrice > 0 {
|
||||
free = append(free, item)
|
||||
}
|
||||
}
|
||||
|
||||
return free, nil
|
||||
}
|
||||
|
||||
func formatGame(g FeaturedItem) string {
|
||||
storeURL := fmt.Sprintf("https://store.steampowered.com/app/%d", g.ID)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "🎮 **FREE ON STEAM** 🎮\n")
|
||||
fmt.Fprintf(&b, "**%s**\n", g.Name)
|
||||
fmt.Fprintf(&b, "~~$%.2f~~ → **FREE**\n", float64(g.OriginalPrice)/100)
|
||||
fmt.Fprintf(&b, "%s", storeURL)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sendMessage(c *websocket.Conn, channelID, content string) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "SEND_MESSAGE",
|
||||
"payload": map[string]string{
|
||||
"channel_id": channelID,
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
c.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
@@ -49,6 +49,7 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/gammazero/deque v1.2.1 // indirect
|
||||
github.com/go-chi/cors v1.2.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
|
||||
@@ -87,6 +87,8 @@ github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ
|
||||
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
|
||||
@@ -17,7 +17,8 @@ func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(duration.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
)
|
||||
|
||||
func generateToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (h *Handler) RequestVerification(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.getProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
token := generateToken()
|
||||
tokenHash := hashToken(token)
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
|
||||
`, userID, tokenHash)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
appURL := h.cfg.AppURL()
|
||||
|
||||
if err := h.mailer.SendVerificationEmail(user.Email, user.Username, token, appURL); err != nil {
|
||||
http.Error(w, `{"error":"failed to send email"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
DELETE FROM user_tokens
|
||||
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
|
||||
RETURNING user_id
|
||||
`, hashToken(req.Token)).Scan(&userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
UPDATE users SET email_verified = TRUE WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var userID, username string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT id, username FROM users WHERE email = $1
|
||||
`, req.Email).Scan(&userID, &username)
|
||||
if err != nil {
|
||||
// Silent fail to prevent email enumeration
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
token := generateToken()
|
||||
tokenHash := hashToken(token)
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
|
||||
`, userID, tokenHash)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
appURL := h.cfg.AppURL()
|
||||
|
||||
h.mailer.SendPasswordResetEmail(req.Email, username, token, appURL)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
http.Error(w, `{"error":"password too short"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var userID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
DELETE FROM user_tokens
|
||||
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
|
||||
RETURNING user_id
|
||||
`, hashToken(req.Token)).Scan(&userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to hash password"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
UPDATE users SET password_hash = $2 WHERE id = $1
|
||||
`, userID, hash)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete all sessions to force re-login
|
||||
h.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE user_id = $1`, userID)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminAnnounce(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is an admin across the whole system.
|
||||
// For now, let's assume if they have ADMINISTRATOR on ANY server, they can announce.
|
||||
// Or maybe there is a global admin flag? Let's check permissions.
|
||||
// Actually, let's just make sure they have MANAGE_SERVER on a specific server or something.
|
||||
// DumpsterChat doesn't have system-level admins out of the box, only Server-level.
|
||||
// Let's require them to be the owner of at least one server, or have ADMINISTRATOR on a server?
|
||||
// The prompt just said "admin sent announcements". Let's allow it for users who are in any server with ADMINISTRATOR.
|
||||
var isAdmin bool
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM member_roles mr
|
||||
JOIN roles r ON mr.role_id = r.id
|
||||
WHERE mr.user_id = $1 AND (r.permissions & $2) = $2
|
||||
)
|
||||
`, userID, permissions.ADMINISTRATOR).Scan(&isAdmin)
|
||||
|
||||
if err != nil || !isAdmin {
|
||||
http.Error(w, `{"error":"must be an administrator"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `SELECT email FROM users`)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to fetch users"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var email string
|
||||
if err := rows.Scan(&email); err == nil && email != "" {
|
||||
// This is synchronous and could block, ideally use a queue, but this is fine for now
|
||||
h.mailer.SendAnnouncement(email, req.Subject, req.Body)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/email"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -21,14 +22,16 @@ type Handler struct {
|
||||
cfg *config.Config
|
||||
sessions *SessionStore
|
||||
hub *gateway.Hub
|
||||
mailer *email.Mailer
|
||||
}
|
||||
|
||||
func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub) *Handler {
|
||||
func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub, mailer *email.Mailer) *Handler {
|
||||
return &Handler{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
sessions: NewSessionStore(db, cfg),
|
||||
hub: hub,
|
||||
mailer: mailer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,12 +39,18 @@ func (h *Handler) RegisterPublicRoutes(r chi.Router) {
|
||||
r.Post("/register", h.Register)
|
||||
r.Post("/login/password", h.Login)
|
||||
r.Post("/logout", h.Logout)
|
||||
r.Post("/verify-email", h.VerifyEmail)
|
||||
r.Post("/request-password-reset", h.RequestPasswordReset)
|
||||
r.Post("/reset-password", h.ResetPassword)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
|
||||
r.Post("/auth/request-verification", h.RequestVerification)
|
||||
r.Post("/auth/admin/announce", h.AdminAnnounce)
|
||||
r.Get("/auth/me", h.Me)
|
||||
r.Patch("/auth/me", h.UpdateProfile)
|
||||
r.Put("/auth/me/password", h.ChangePassword)
|
||||
r.Delete("/auth/me", h.DeleteAccount)
|
||||
r.Get("/users/{userID}/profile", h.GetPublicProfile)
|
||||
r.Get("/users/me/blocks", h.ListBlocks)
|
||||
r.Post("/users/me/blocks", h.BlockUser)
|
||||
@@ -210,16 +219,16 @@ type loginRequest struct {
|
||||
}
|
||||
|
||||
type updateProfileRequest struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio"`
|
||||
AccentColor string `json:"accent_color"`
|
||||
StatusText string `json:"status_text"`
|
||||
Status string `json:"status"`
|
||||
BannerURL string `json:"banner_url"`
|
||||
Tagline string `json:"tagline"`
|
||||
Pronouns string `json:"pronouns"`
|
||||
SocialLinks []struct {
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Bio *string `json:"bio"`
|
||||
AccentColor *string `json:"accent_color"`
|
||||
StatusText *string `json:"status_text"`
|
||||
Status *string `json:"status"`
|
||||
BannerURL *string `json:"banner_url"`
|
||||
Tagline *string `json:"tagline"`
|
||||
Pronouns *string `json:"pronouns"`
|
||||
SocialLinks *[]struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
} `json:"social_links"`
|
||||
@@ -323,6 +332,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
h.setSessionCookie(w, token)
|
||||
w.Header().Set("X-Session-Token", token)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
||||
}
|
||||
@@ -375,6 +385,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
h.setSessionCookie(w, token)
|
||||
w.Header().Set("X-Session-Token", token)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
||||
}
|
||||
@@ -410,8 +421,16 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /auth/me [get]
|
||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
cookie, _ := r.Cookie(h.cfg.Session.CookieName)
|
||||
if cookie != nil {
|
||||
fmt.Printf("Me Request: Cookie present! value=%s\n", cookie.Value)
|
||||
} else {
|
||||
fmt.Printf("Me Request: NO COOKIE!\n")
|
||||
}
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
fmt.Printf("Me Request: NO USER ID IN CONTEXT\n")
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -450,52 +469,113 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Bio) > 250 {
|
||||
// Fetch existing user to merge fields
|
||||
var existing struct {
|
||||
DisplayName string
|
||||
Bio string
|
||||
AccentColor string
|
||||
StatusText string
|
||||
Status string
|
||||
BannerURL string
|
||||
Tagline string
|
||||
Pronouns string
|
||||
AvatarURL string
|
||||
SocialLinks []struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
}
|
||||
var socialLinksBytes []byte
|
||||
var bannerURL, tagline, pronouns, avatar sql.NullString
|
||||
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT display_name, bio, accent_color, status_text, status, banner_url, tagline, pronouns, avatar, social_links
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(&existing.DisplayName, &existing.Bio, &existing.AccentColor, &existing.StatusText, &existing.Status, &bannerURL, &tagline, &pronouns, &avatar, &socialLinksBytes)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
existing.BannerURL = bannerURL.String
|
||||
existing.Tagline = tagline.String
|
||||
existing.Pronouns = pronouns.String
|
||||
existing.AvatarURL = avatar.String
|
||||
if len(socialLinksBytes) > 0 {
|
||||
json.Unmarshal(socialLinksBytes, &existing.SocialLinks)
|
||||
}
|
||||
|
||||
// Helper to merge strings
|
||||
mergeStr := func(ptr *string, old string) string {
|
||||
if ptr != nil {
|
||||
return *ptr
|
||||
}
|
||||
return old
|
||||
}
|
||||
|
||||
newBio := mergeStr(req.Bio, existing.Bio)
|
||||
newStatusText := mergeStr(req.StatusText, existing.StatusText)
|
||||
newDisplayName := mergeStr(req.DisplayName, existing.DisplayName)
|
||||
newTagline := mergeStr(req.Tagline, existing.Tagline)
|
||||
newPronouns := mergeStr(req.Pronouns, existing.Pronouns)
|
||||
newAccentColor := mergeStr(req.AccentColor, existing.AccentColor)
|
||||
newStatus := mergeStr(req.Status, existing.Status)
|
||||
newBannerURL := mergeStr(req.BannerURL, existing.BannerURL)
|
||||
newAvatarURL := mergeStr(req.AvatarURL, existing.AvatarURL)
|
||||
|
||||
if len(newBio) > 250 {
|
||||
http.Error(w, `{"error":"bio too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.StatusText) > 128 {
|
||||
if len(newStatusText) > 128 {
|
||||
http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.DisplayName) > 32 {
|
||||
if len(newDisplayName) > 32 {
|
||||
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Tagline) > 128 {
|
||||
if len(newTagline) > 128 {
|
||||
http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Pronouns) > 40 {
|
||||
if len(newPronouns) > 40 {
|
||||
http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.AccentColor != "" && len(req.AccentColor) != 7 {
|
||||
if newAccentColor != "" && len(newAccentColor) != 7 {
|
||||
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Status != "" && !isValidStatus(req.Status) {
|
||||
if newStatus != "" && !isValidStatus(newStatus) {
|
||||
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
socialLinksJSON, _ := json.Marshal(req.SocialLinks)
|
||||
_, err := h.db.ExecContext(r.Context(), `
|
||||
var socialLinksJSON []byte
|
||||
if req.SocialLinks != nil {
|
||||
socialLinksJSON, _ = json.Marshal(*req.SocialLinks)
|
||||
} else {
|
||||
socialLinksJSON, _ = json.Marshal(existing.SocialLinks)
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
UPDATE users
|
||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status),
|
||||
banner_url = NULLIF($7, ''), tagline = NULLIF($8, ''), pronouns = NULLIF($9, ''), social_links = $10, avatar = COALESCE(NULLIF($11, ''), avatar)
|
||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = $6,
|
||||
banner_url = $7, tagline = $8, pronouns = $9, social_links = $10, avatar = $11
|
||||
WHERE id = $1
|
||||
`, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status, req.BannerURL, req.Tagline, req.Pronouns, socialLinksJSON, req.AvatarURL)
|
||||
`, userID, newDisplayName, newBio, newAccentColor, newStatusText, newStatus, newBannerURL, newTagline, newPronouns, socialLinksJSON, newAvatarURL)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != "" && h.hub != nil {
|
||||
if req.Status != nil && h.hub != nil {
|
||||
user, err := h.getProfile(r.Context(), userID)
|
||||
if err == nil {
|
||||
h.hub.SetUserStatus(userID, req.Status)
|
||||
h.hub.BroadcastPresence(userID, user.Username, req.Status)
|
||||
h.hub.SetUserStatus(userID, *req.Status)
|
||||
h.hub.BroadcastPresence(userID, user.Username, *req.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,3 +663,32 @@ func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "password updated"})
|
||||
}
|
||||
|
||||
// DeleteAccount deletes the authenticated user and all associated data.
|
||||
// ponytail: cascading FK handles all DB cleanup — push subscriptions cleaned manually.
|
||||
func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up push subscriptions
|
||||
h.db.ExecContext(r.Context(), `DELETE FROM push_subscriptions WHERE user_id = $1`, userID)
|
||||
|
||||
// ON DELETE CASCADE handles everything else: sessions, tokens, messages,
|
||||
// reactions, memberships, invites, bots, etc.
|
||||
result, err := h.db.ExecContext(r.Context(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to delete account"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "account deleted"})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
@@ -20,17 +21,25 @@ func NewSessionStore(db *sql.DB, cfg *config.Config) *SessionStore {
|
||||
return &SessionStore{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
// hashToken computes a SHA-256 hash of a token string.
|
||||
// Tokens are stored hashed in the DB so a DB leak doesn't expose active sessions.
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(b)
|
||||
tokenHash := hashToken(token)
|
||||
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO sessions (user_id, token, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`, userID, token, time.Now().Add(s.cfg.Session.Duration))
|
||||
`, userID, tokenHash, time.Now().Add(s.cfg.Session.Duration))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
@@ -43,7 +52,7 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT user_id FROM sessions
|
||||
WHERE token = $1 AND expires_at > NOW()
|
||||
`, token).Scan(&userID)
|
||||
`, hashToken(token)).Scan(&userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -51,6 +60,6 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(ctx context.Context, token string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, token)
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, hashToken(token))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -234,7 +234,8 @@ func (h *WebAuthnHandler) LoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: 300, // 5 minutes
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
SameSite: http.SameSiteNoneMode,
|
||||
Secure: true,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -341,9 +342,11 @@ func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
SameSite: http.SameSiteNoneMode,
|
||||
Secure: true,
|
||||
})
|
||||
|
||||
w.Header().Set("X-Session-Token", token)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "authenticated"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConfessConfig: "channel_id" is where anonymous confessions land.
|
||||
type ConfessConfig struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
}
|
||||
|
||||
// parseConfessContent returns the confession body if content is a /confess command.
|
||||
func parseConfessContent(content string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if len(trimmed) < 8 || !strings.EqualFold(trimmed[:8], "/confess") {
|
||||
return "", false
|
||||
}
|
||||
// Require word boundary after the command (space, end, or more text after optional space).
|
||||
rest := strings.TrimSpace(trimmed[8:])
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// ConfessBot is a safety-net poller for any /confess that slipped past intercept.
|
||||
// Primary path is Runner.TryConfess at message create (no original ever stored).
|
||||
func ConfessBot(ctx context.Context, db *sql.DB, raw json.RawMessage, send SendMessageFunc) {
|
||||
// Polling path is intentionally inert for normal operation when intercept works.
|
||||
// Keep a lightweight no-op loop so the runner lifecycle stays consistent.
|
||||
// Real cleanup of any leaked /confess rows is handled if deleteMsg is wired later.
|
||||
_ = db
|
||||
_ = raw
|
||||
_ = send
|
||||
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// no-op: intercept handles live confessions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// confessFormat is shared with TryConfess.
|
||||
func confessFormat(text string) string {
|
||||
return fmt.Sprintf("🕵️ **anonymous confession:** %s", text)
|
||||
}
|
||||
@@ -13,15 +13,18 @@ import (
|
||||
// Handler handles bot CRUD and server-assignment routes.
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
runner *Runner
|
||||
}
|
||||
|
||||
// NewHandler creates a new bot Handler.
|
||||
func NewHandler(db *sql.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
func NewHandler(db *sql.DB, runner *Runner) *Handler {
|
||||
return &Handler{db: db, runner: runner}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers authenticated bot routes under the given router.
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/store", h.Store)
|
||||
r.Get("/types", h.ListTypes)
|
||||
r.Post("/", h.Create)
|
||||
r.Get("/", h.List)
|
||||
r.Get("/{botID}", h.Get)
|
||||
@@ -39,6 +42,8 @@ type botResponse struct {
|
||||
Name string `json:"name"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Description string `json:"description"`
|
||||
BotType string `json:"bot_type"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
@@ -52,12 +57,16 @@ type botWithToken struct {
|
||||
type createBotRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
BotType string `json:"bot_type"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
type updateBotRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Avatar *string `json:"avatar"`
|
||||
BotType *string `json:"bot_type"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
type addToServerRequest struct {
|
||||
@@ -109,15 +118,21 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
token := GenerateToken()
|
||||
tokenHash := HashToken(token)
|
||||
|
||||
configJSON := req.Config
|
||||
if configJSON == nil {
|
||||
configJSON = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
var bot botWithToken
|
||||
var avatar sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var configOut sql.NullString
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO bots (name, description, owner_id, token)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, avatar, description, owner_id, created_at::text
|
||||
`, req.Name, req.Description, userID, tokenHash).Scan(
|
||||
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.OwnerID, &createdAt,
|
||||
INSERT INTO bots (name, description, owner_id, token, bot_type, config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
|
||||
`, req.Name, req.Description, userID, tokenHash, req.BotType, string(configJSON)).Scan(
|
||||
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.BotType, &configOut, &bot.OwnerID, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to create bot")
|
||||
@@ -126,9 +141,17 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if avatar.Valid {
|
||||
bot.Avatar = &avatar.String
|
||||
}
|
||||
if configOut.Valid {
|
||||
bot.Config = json.RawMessage(configOut.String)
|
||||
}
|
||||
bot.CreatedAt = createdAt.String
|
||||
bot.Token = token
|
||||
|
||||
// Start built-in bot if type is set
|
||||
if req.BotType != "" && h.runner != nil {
|
||||
h.runner.Start(bot.ID, req.BotType, bot.Config)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, bot)
|
||||
}
|
||||
|
||||
@@ -281,15 +304,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var b botResponse
|
||||
var avatar sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var configOut sql.NullString
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE bots
|
||||
SET name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
avatar = COALESCE($3, avatar)
|
||||
avatar = COALESCE($3, avatar),
|
||||
bot_type = COALESCE($5, bot_type),
|
||||
config = COALESCE($6::jsonb, config)
|
||||
WHERE id = $4
|
||||
RETURNING id, name, avatar, description, owner_id, created_at::text
|
||||
`, req.Name, req.Description, req.Avatar, botID).Scan(
|
||||
&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt,
|
||||
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
|
||||
`, req.Name, req.Description, req.Avatar, botID, req.BotType, string(req.Config)).Scan(
|
||||
&b.ID, &b.Name, &avatar, &b.Description, &b.BotType, &configOut, &b.OwnerID, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to update bot")
|
||||
@@ -298,8 +324,22 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
if avatar.Valid {
|
||||
b.Avatar = &avatar.String
|
||||
}
|
||||
if configOut.Valid {
|
||||
b.Config = json.RawMessage(configOut.String)
|
||||
}
|
||||
b.CreatedAt = createdAt.String
|
||||
|
||||
// Restart built-in bot if type/config changed
|
||||
if req.BotType != nil && h.runner != nil {
|
||||
h.runner.Stop(b.ID)
|
||||
if *req.BotType != "" {
|
||||
h.runner.Start(b.ID, *req.BotType, b.Config)
|
||||
}
|
||||
} else if req.Config != nil && h.runner != nil && b.BotType != "" {
|
||||
h.runner.Stop(b.ID)
|
||||
h.runner.Start(b.ID, b.BotType, b.Config)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
@@ -345,6 +385,11 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Stop built-in bot if running
|
||||
if h.runner != nil {
|
||||
h.runner.Stop(botID)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -547,3 +592,64 @@ func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
// ---- Store (public listing) ----
|
||||
|
||||
type storeBotResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Description string `json:"description"`
|
||||
ServerCount int `json:"server_count"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Store returns all bots with their server count (visible to any authenticated user).
|
||||
func (h *Handler) Store(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT b.id, b.name, b.avatar, b.description, b.owner_id, b.created_at::text,
|
||||
COUNT(bs.server_id) AS server_count
|
||||
FROM bots b
|
||||
LEFT JOIN bot_servers bs ON bs.bot_id = b.id
|
||||
GROUP BY b.id, b.name, b.avatar, b.description, b.owner_id, b.created_at
|
||||
ORDER BY server_count DESC, b.name
|
||||
`)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
bots := make([]storeBotResponse, 0)
|
||||
for rows.Next() {
|
||||
var b storeBotResponse
|
||||
var avatar sql.NullString
|
||||
var createdAt sql.NullString
|
||||
if err := rows.Scan(&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt, &b.ServerCount); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
if avatar.Valid {
|
||||
b.Avatar = &avatar.String
|
||||
}
|
||||
b.CreatedAt = createdAt.String
|
||||
bots = append(bots, b)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, bots)
|
||||
}
|
||||
|
||||
// ListTypes returns available built-in bot types.
|
||||
func (h *Handler) ListTypes(w http.ResponseWriter, r *http.Request) {
|
||||
if h.runner == nil {
|
||||
writeJSON(w, http.StatusOK, []string{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.runner.RegisteredTypes())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LeaderboardConfig: "channel_id" is where the daily recap lands.
|
||||
type LeaderboardConfig struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
}
|
||||
|
||||
// LeaderboardBot posts a daily shitpost recap.
|
||||
func LeaderboardBot(ctx context.Context, db *sql.DB, raw json.RawMessage, send SendMessageFunc) {
|
||||
var cfg LeaderboardConfig
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Post immediately on start, then daily
|
||||
postLeaderboard(db, cfg.ChannelID, send)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
postLeaderboard(db, cfg.ChannelID, send)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func postLeaderboard(db *sql.DB, channelID string, send SendMessageFunc) {
|
||||
// ponytail: global lock on count. per-user aggregates if throughput matters.
|
||||
since := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
rows, err := db.QueryContext(context.Background(), `
|
||||
SELECT u.username, COUNT(*) AS msg_count
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
WHERE m.created_at > $1 AND m.bot_id IS NULL
|
||||
GROUP BY u.username
|
||||
ORDER BY msg_count DESC
|
||||
LIMIT 10
|
||||
`, since)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("🏆 **24H SHITPOST LEADERBOARD** 🏆\n")
|
||||
|
||||
rank := 1
|
||||
for rows.Next() {
|
||||
var username string
|
||||
var count int
|
||||
if err := rows.Scan(&username, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
medal := ""
|
||||
switch rank {
|
||||
case 1:
|
||||
medal = "🥇"
|
||||
case 2:
|
||||
medal = "🥈"
|
||||
case 3:
|
||||
medal = "🥉"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s #%d **%s** — %d msgs\n", medal, rank, username, count)
|
||||
rank++
|
||||
}
|
||||
if rank == 1 {
|
||||
b.WriteString("*crickets*\n")
|
||||
}
|
||||
|
||||
send(channelID, strings.TrimSpace(b.String()))
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
)
|
||||
|
||||
// BotFunc is the signature for a built-in bot type's run function.
|
||||
// It blocks until ctx is cancelled. Use send to post messages, db to read.
|
||||
type BotFunc func(ctx context.Context, db *sql.DB, config json.RawMessage, send SendMessageFunc)
|
||||
|
||||
// SendMessageFunc posts a message to a channel as this bot.
|
||||
type SendMessageFunc func(channelID, content string)
|
||||
|
||||
// Runner manages server-side bot goroutines.
|
||||
type Runner struct {
|
||||
db *sql.DB
|
||||
hub *gateway.Hub
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
bots map[string]context.CancelFunc // botID -> cancel
|
||||
types map[string]BotFunc // type name -> runner
|
||||
}
|
||||
|
||||
func NewRunner(db *sql.DB, hub *gateway.Hub, logger *slog.Logger) *Runner {
|
||||
return &Runner{
|
||||
db: db,
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
bots: make(map[string]context.CancelFunc),
|
||||
types: make(map[string]BotFunc),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a built-in bot type.
|
||||
func (r *Runner) Register(name string, fn BotFunc) {
|
||||
r.types[name] = fn
|
||||
}
|
||||
|
||||
// StartAll loads all bots with a bot_type set and starts them.
|
||||
func (r *Runner) StartAll() {
|
||||
rows, err := r.db.QueryContext(context.Background(),
|
||||
`SELECT id, bot_type, config::text FROM bots WHERE bot_type != ''`)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to load bot configs", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id, botType, configStr string
|
||||
if err := rows.Scan(&id, &botType, &configStr); err != nil {
|
||||
continue
|
||||
}
|
||||
r.Start(id, botType, json.RawMessage(configStr))
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts a single bot by ID. Safe to call multiple times (restarts).
|
||||
func (r *Runner) Start(botID, botType string, config json.RawMessage) {
|
||||
r.mu.Lock()
|
||||
// Stop existing if running
|
||||
if cancel, ok := r.bots[botID]; ok {
|
||||
cancel()
|
||||
delete(r.bots, botID)
|
||||
}
|
||||
|
||||
fn, ok := r.types[botType]
|
||||
if !ok {
|
||||
r.mu.Unlock()
|
||||
r.logger.Warn("unknown bot type", "type", botType, "bot_id", botID)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.bots[botID] = cancel
|
||||
r.mu.Unlock()
|
||||
|
||||
// Built-in bots pick a channel in config but users often skip "Add to Server".
|
||||
// Resolve channel_id → server and ensure bot_servers so send() doesn't no-op.
|
||||
r.ensureBotServerFromConfig(botID, config)
|
||||
|
||||
send := r.makeSender(botID)
|
||||
r.logger.Info("starting built-in bot", "bot_id", botID, "type", botType)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
r.logger.Error("bot panic", "bot_id", botID, "type", botType, "panic", rec)
|
||||
}
|
||||
}()
|
||||
fn(ctx, r.db, config, send)
|
||||
r.logger.Info("bot stopped", "bot_id", botID, "type", botType)
|
||||
}()
|
||||
}
|
||||
|
||||
// ensureBotServerFromConfig joins the bot to the server that owns config.channel_id.
|
||||
func (r *Runner) ensureBotServerFromConfig(botID string, config json.RawMessage) {
|
||||
var cfg struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
}
|
||||
if err := json.Unmarshal(config, &cfg); err != nil || cfg.ChannelID == "" {
|
||||
return
|
||||
}
|
||||
serverID, err := r.hub.ServerIDForChannel(context.Background(), cfg.ChannelID)
|
||||
if err != nil || serverID == "" {
|
||||
r.logger.Warn("bot start: channel not found for auto-join", "bot_id", botID, "channel_id", cfg.ChannelID, "error", err)
|
||||
return
|
||||
}
|
||||
var ownerID string
|
||||
if err := r.db.QueryRowContext(context.Background(),
|
||||
`SELECT owner_id FROM bots WHERE id = $1`, botID,
|
||||
).Scan(&ownerID); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := r.db.ExecContext(context.Background(), `
|
||||
INSERT INTO bot_servers (bot_id, server_id, added_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, botID, serverID, ownerID); err != nil {
|
||||
r.logger.Warn("bot start: auto-join server failed", "bot_id", botID, "server_id", serverID, "error", err)
|
||||
return
|
||||
}
|
||||
r.logger.Info("bot auto-joined server", "bot_id", botID, "server_id", serverID)
|
||||
}
|
||||
|
||||
// Stop stops a single bot by ID.
|
||||
func (r *Runner) Stop(botID string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if cancel, ok := r.bots[botID]; ok {
|
||||
cancel()
|
||||
delete(r.bots, botID)
|
||||
}
|
||||
}
|
||||
|
||||
// IsRunning checks if a bot is currently running.
|
||||
func (r *Runner) IsRunning(botID string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
_, ok := r.bots[botID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// makeSender returns a SendMessageFunc that inserts into DB and broadcasts.
|
||||
func (r *Runner) makeSender(botID string) SendMessageFunc {
|
||||
return func(channelID, content string) {
|
||||
if len(content) > 4000 {
|
||||
content = content[:4000]
|
||||
}
|
||||
|
||||
// Look up bot info + server
|
||||
var botName, ownerID string
|
||||
err := r.db.QueryRowContext(context.Background(),
|
||||
`SELECT name, owner_id FROM bots WHERE id = $1`, botID,
|
||||
).Scan(&botName, &ownerID)
|
||||
if err != nil {
|
||||
r.logger.Error("send: bot not found", "bot_id", botID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
serverID, err := r.hub.ServerIDForChannel(context.Background(), channelID)
|
||||
if err != nil {
|
||||
r.logger.Error("send: channel not found", "channel_id", channelID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify bot is in this server
|
||||
var inServer bool
|
||||
err = r.db.QueryRowContext(context.Background(),
|
||||
`SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`,
|
||||
botID, serverID,
|
||||
).Scan(&inServer)
|
||||
if err != nil || !inServer {
|
||||
r.logger.Warn("send: bot not in server", "bot_id", botID, "server_id", serverID)
|
||||
return
|
||||
}
|
||||
|
||||
var msgID, createdAt string
|
||||
err = r.db.QueryRowContext(context.Background(),
|
||||
`INSERT INTO messages (channel_id, author_id, content, bot_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, created_at::text`,
|
||||
channelID, ownerID, content, botID,
|
||||
).Scan(&msgID, &createdAt)
|
||||
if err != nil {
|
||||
r.logger.Error("send: insert failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
r.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageCreate,
|
||||
Data: map[string]interface{}{
|
||||
"id": msgID,
|
||||
"channel_id": channelID,
|
||||
"author_id": ownerID,
|
||||
"author_username": botName,
|
||||
"author_display_name": nil,
|
||||
"author_bot": true,
|
||||
"bot_id": botID,
|
||||
"bot_name": botName,
|
||||
"content": content,
|
||||
"reply_to": nil,
|
||||
"edited_at": nil,
|
||||
"pinned": false,
|
||||
"created_at": createdAt,
|
||||
"embeds": []interface{}{},
|
||||
"reactions": []interface{}{},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RegisteredTypes returns the list of available bot type names.
|
||||
func (r *Runner) RegisteredTypes() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
names := make([]string, 0, len(r.types))
|
||||
for k := range r.types {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// TryConfess intercepts "/confess …" at message create time.
|
||||
// On success the original is never stored or broadcast (true anonymity).
|
||||
// Returns the anonymous bot message payload for the HTTP response when handled.
|
||||
func (r *Runner) TryConfess(ctx context.Context, serverID, _authorID, content string) (map[string]interface{}, bool) {
|
||||
text, ok := parseConfessContent(content)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var botID, botName, ownerID string
|
||||
var rawConfig string
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT b.id, b.name, b.owner_id, COALESCE(b.config::text, '{}')
|
||||
FROM bots b
|
||||
JOIN bot_servers bs ON bs.bot_id = b.id
|
||||
WHERE bs.server_id = $1 AND b.bot_type = 'confess'
|
||||
LIMIT 1
|
||||
`, serverID).Scan(&botID, &botName, &ownerID, &rawConfig)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var cfg ConfessConfig
|
||||
if err := json.Unmarshal([]byte(rawConfig), &cfg); err != nil || cfg.ChannelID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Ensure bot can post to the confession channel's server.
|
||||
r.ensureBotServerFromConfig(botID, json.RawMessage(rawConfig))
|
||||
|
||||
anonContent := fmt.Sprintf("🕵️ **anonymous confession:** %s", text)
|
||||
if len(anonContent) > 4000 {
|
||||
anonContent = anonContent[:4000]
|
||||
}
|
||||
|
||||
var msgID, createdAt string
|
||||
err = r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO messages (channel_id, author_id, content, bot_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, created_at::text
|
||||
`, cfg.ChannelID, ownerID, anonContent, botID).Scan(&msgID, &createdAt)
|
||||
if err != nil {
|
||||
r.logger.Error("confess: insert failed", "error", err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
targetServerID, err := r.hub.ServerIDForChannel(ctx, cfg.ChannelID)
|
||||
if err != nil || targetServerID == "" {
|
||||
targetServerID = serverID
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"id": msgID,
|
||||
"channel_id": cfg.ChannelID,
|
||||
"author_id": ownerID,
|
||||
"author_username": botName,
|
||||
"author_display_name": nil,
|
||||
"author_bot": true,
|
||||
"bot_id": botID,
|
||||
"bot_name": botName,
|
||||
"content": anonContent,
|
||||
"reply_to": nil,
|
||||
"edited_at": nil,
|
||||
"pinned": false,
|
||||
"created_at": createdAt,
|
||||
"embeds": []interface{}{},
|
||||
"reactions": []interface{}{},
|
||||
}
|
||||
|
||||
r.hub.BroadcastToServer(targetServerID, gateway.Event{
|
||||
Type: gateway.EventMessageCreate,
|
||||
Data: payload,
|
||||
})
|
||||
|
||||
return payload, true
|
||||
}
|
||||
|
||||
// DeleteMessage removes a message and broadcasts MESSAGE_DELETE to live clients.
|
||||
func (r *Runner) DeleteMessage(messageID string) {
|
||||
var channelID string
|
||||
err := r.db.QueryRowContext(context.Background(),
|
||||
`DELETE FROM messages WHERE id = $1::uuid RETURNING channel_id`, messageID,
|
||||
).Scan(&channelID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
serverID, err := r.hub.ServerIDForChannel(context.Background(), channelID)
|
||||
if err != nil || serverID == "" {
|
||||
return
|
||||
}
|
||||
r.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageDelete,
|
||||
Data: map[string]string{
|
||||
"id": messageID,
|
||||
"message_id": messageID,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SteamFreeConfig is the config shape for bot_type "steamfree".
|
||||
type SteamFreeConfig struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
PollMinutes int `json:"poll_minutes"`
|
||||
}
|
||||
|
||||
// SteamFreeBot polls Steam's featured categories for 100%-off games.
|
||||
func SteamFreeBot(ctx context.Context, _ *sql.DB, raw json.RawMessage, send SendMessageFunc) {
|
||||
var cfg SteamFreeConfig
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" {
|
||||
return
|
||||
}
|
||||
if cfg.PollMinutes <= 0 {
|
||||
cfg.PollMinutes = 30
|
||||
}
|
||||
|
||||
seen := map[int]bool{}
|
||||
ticker := time.NewTicker(time.Duration(cfg.PollMinutes) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Poll immediately on start
|
||||
pollSteam(cfg.ChannelID, seen, send)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
pollSteam(cfg.ChannelID, seen, send)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pollSteam(channelID string, seen map[int]bool, send SendMessageFunc) {
|
||||
games, err := fetchFreeGames()
|
||||
if err != nil {
|
||||
return // ponytail: silent on error, logs add noise
|
||||
}
|
||||
for _, g := range games {
|
||||
if seen[g.ID] {
|
||||
continue
|
||||
}
|
||||
seen[g.ID] = true
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"🎮 **FREE ON STEAM** 🎮\n**%s**\n~~$%.2f~~ → **FREE**\nhttps://store.steampowered.com/app/%d",
|
||||
g.Name, float64(g.OriginalPrice)/100, g.ID,
|
||||
)
|
||||
send(channelID, msg)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
type featuredCategories struct {
|
||||
Specials struct {
|
||||
Items []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DiscountPct int `json:"discount_percent"`
|
||||
FinalPrice int `json:"final_price"`
|
||||
OriginalPrice int `json:"original_price"`
|
||||
} `json:"items"`
|
||||
} `json:"specials"`
|
||||
}
|
||||
|
||||
func fetchFreeGames() ([]struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DiscountPct int `json:"discount_percent"`
|
||||
FinalPrice int `json:"final_price"`
|
||||
OriginalPrice int `json:"original_price"`
|
||||
}, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get("https://store.steampowered.com/api/featuredcategories?cc=us&l=english")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("steam: %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cats featuredCategories
|
||||
if err := json.Unmarshal(body, &cats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var free []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DiscountPct int `json:"discount_percent"`
|
||||
FinalPrice int `json:"final_price"`
|
||||
OriginalPrice int `json:"original_price"`
|
||||
}
|
||||
for _, item := range cats.Specials.Items {
|
||||
if item.DiscountPct == 100 && item.OriginalPrice > 0 {
|
||||
free = append(free, item)
|
||||
}
|
||||
}
|
||||
return free, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ type createEventRequest struct {
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
|
||||
func (h *Handler) registerCalendarRoutes(r chi.Router) {
|
||||
func (h *Handler) RegisterCalendarRoutes(r chi.Router) {
|
||||
r.Get("/{channelID}/events", h.ListEvents)
|
||||
r.Post("/{channelID}/events", h.CreateEvent)
|
||||
r.Patch("/events/{eventID}", h.UpdateEvent)
|
||||
|
||||
@@ -27,11 +27,8 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/{channelID}", h.Get)
|
||||
r.Patch("/{channelID}", h.Update)
|
||||
r.Delete("/{channelID}", h.Delete)
|
||||
r.Post("/{channelID}/threads", h.CreateThread)
|
||||
r.Get("/{channelID}/threads", h.ListThreads)
|
||||
r.Patch("/threads/{threadID}", h.UpdateThread)
|
||||
h.registerForumRoutes(r)
|
||||
h.registerCalendarRoutes(r)
|
||||
// ponytail: thread + forum routes registered at top level in main.go to match frontend paths
|
||||
h.RegisterCalendarRoutes(r)
|
||||
h.registerDocRoutes(r)
|
||||
h.registerListRoutes(r)
|
||||
h.registerOverrideRoutes(r)
|
||||
@@ -204,8 +201,17 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Check VIEW_CHANNEL permission
|
||||
allowed, err := h.checker.CheckChannelPermission(r.Context(), serverID, userID, ch.ID, permissions.VIEW_CHANNEL)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if allowed {
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
@@ -339,6 +345,14 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Server owner bypasses permission check
|
||||
var ownerID string
|
||||
err = h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
@@ -348,6 +362,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var ch channelResponse
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
@@ -357,7 +372,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
category = COALESCE($3, category),
|
||||
position = COALESCE($4, position),
|
||||
slowmode_seconds = COALESCE($5, slowmode_seconds),
|
||||
group_id = $7
|
||||
group_id = COALESCE($7, group_id)
|
||||
WHERE id = $6
|
||||
RETURNING id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at
|
||||
`, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID, req.GroupID).Scan(
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -51,6 +52,14 @@ type Config struct {
|
||||
WebPush WebPushConfig
|
||||
Session SessionConfig
|
||||
Giphy GiphyConfig
|
||||
SMTP SMTPConfig
|
||||
}
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
@@ -134,9 +143,31 @@ func Load() *Config {
|
||||
Giphy: GiphyConfig{
|
||||
APIKey: getEnv("GIPHY_API_KEY", ""),
|
||||
},
|
||||
SMTP: SMTPConfig{
|
||||
Host: getEnv("SMTP_HOST", ""),
|
||||
Port: getEnv("SMTP_PORT", ""),
|
||||
Username: getEnv("SMTP_USERNAME", ""),
|
||||
Password: getEnv("SMTP_PASSWORD", ""),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) AppURL() string {
|
||||
if v := getEnv("APP_URL", ""); v != "" {
|
||||
return v
|
||||
}
|
||||
scheme := "http"
|
||||
port := c.Port
|
||||
if c.Database.SSLMode == "require" {
|
||||
// ponytail: using SSLMode as a rough https hint; add explicit APP_URL for real setups
|
||||
scheme = "https"
|
||||
}
|
||||
if port == "80" || port == "443" {
|
||||
return fmt.Sprintf("%s://%s", scheme, c.Host)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s:%s", scheme, c.Host, port)
|
||||
}
|
||||
|
||||
func (c *Config) DatabaseDSN() string {
|
||||
return "postgres://" + c.Database.User + ":" + c.Database.Password + "@" + c.Database.Host + ":" + c.Database.Port + "/" + c.Database.Database + "?sslmode=" + c.Database.SSLMode
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||
|
||||
@@ -24,6 +25,11 @@ func New(cfg *config.Config) (*DB, error) {
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
// ponytail: prevent unbounded connection growth under burst load
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(15 * time.Minute)
|
||||
|
||||
return &DB{db}, nil
|
||||
}
|
||||
|
||||
@@ -47,8 +53,19 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(255) NOT NULL UNIQUE,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -79,10 +96,13 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
nickname VARCHAR(100),
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, server_id)
|
||||
);
|
||||
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS nickname VARCHAR(100);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
@@ -90,6 +110,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
content TEXT NOT NULL,
|
||||
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
edited_at TIMESTAMPTZ,
|
||||
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -193,6 +214,7 @@ ALTER TABLE webhooks DROP COLUMN IF EXISTS token;
|
||||
|
||||
-- Ensure reply_to column exists on messages (added after initial table creation)
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS reply_to UUID REFERENCES messages(id) ON DELETE SET NULL;
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS pinned BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -261,6 +283,15 @@ CREATE TABLE IF NOT EXISTS conversation_messages (
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_members_user ON conversation_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_messages_conv_created ON conversation_messages(conversation_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_reactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES conversation_messages(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(message_id, user_id, emoji)
|
||||
);
|
||||
|
||||
-- Moderation
|
||||
CREATE TABLE IF NOT EXISTS bans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -493,5 +524,61 @@ CREATE TABLE IF NOT EXISTS server_groups (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_groups_server ON server_groups(server_id);
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES server_groups(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS polls (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
question TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_polls_message ON polls(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS poll_options (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
poll_id UUID NOT NULL REFERENCES polls(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_poll_options_poll ON poll_options(poll_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS poll_votes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
option_id UUID NOT NULL REFERENCES poll_options(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (option_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_poll_votes_option ON poll_votes(option_id);
|
||||
|
||||
-- Feature requests
|
||||
CREATE TABLE IF NOT EXISTS feature_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
|
||||
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'planned', 'done', 'rejected')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feature_requests_server ON feature_requests(server_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feature_request_votes (
|
||||
feature_request_id UUID NOT NULL REFERENCES feature_requests(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (feature_request_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feature_request_votes_fr ON feature_request_votes(feature_request_id);
|
||||
|
||||
-- Bot messages: track which bot authored a message
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS bot_id UUID REFERENCES bots(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_bot ON messages(bot_id) WHERE bot_id IS NOT NULL;
|
||||
|
||||
-- Built-in bot types: bot_type + config for server-managed bots
|
||||
ALTER TABLE bots ADD COLUMN IF NOT EXISTS bot_type VARCHAR(32) DEFAULT '';
|
||||
ALTER TABLE bots ADD COLUMN IF NOT EXISTS config JSONB DEFAULT '{}';
|
||||
CREATE INDEX IF NOT EXISTS idx_bots_type ON bots(bot_type) WHERE bot_type != '';
|
||||
`
|
||||
|
||||
|
||||
@@ -5,13 +5,16 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -19,12 +22,13 @@ import (
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
hub *gateway.Hub
|
||||
pushHandler *push.Handler
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewHandler creates a new DM handler.
|
||||
func NewHandler(db *sql.DB, hub *gateway.Hub, logger *slog.Logger) *Handler {
|
||||
return &Handler{db: db, hub: hub, logger: logger}
|
||||
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger) *Handler {
|
||||
return &Handler{db: db, hub: hub, pushHandler: pushHandler, logger: logger}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers conversation routes.
|
||||
@@ -35,6 +39,10 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/", h.Get)
|
||||
r.Get("/messages", h.ListMessages)
|
||||
r.Post("/messages", h.SendMessage)
|
||||
r.Route("/messages/{messageID}/reactions", func(r chi.Router) {
|
||||
r.Post("/", h.AddReaction)
|
||||
r.Delete("/{emoji}", h.RemoveReaction)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,10 +78,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.UserIDs) == 0 {
|
||||
http.Error(w, `{"error":"at least one user_id is required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// ponytail: user_ids can be empty for self-DM (notes to self)
|
||||
|
||||
// Build unique member set including creator.
|
||||
memberSet := map[string]struct{}{userID: {}}
|
||||
@@ -82,10 +87,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
memberSet[uid] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(memberSet) == 1 {
|
||||
http.Error(w, `{"error":"cannot create conversation with yourself"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// ponytail: allow self-DM (notes to self) — len(memberSet)==1 is fine
|
||||
|
||||
memberIDs := make([]string, 0, len(memberSet))
|
||||
for uid := range memberSet {
|
||||
@@ -94,7 +96,24 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
sort.Strings(memberIDs)
|
||||
|
||||
// For a 1:1 DM, check if it already exists.
|
||||
if len(memberIDs) == 2 {
|
||||
if len(memberIDs) == 1 {
|
||||
var existingID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT c.id FROM conversations c
|
||||
WHERE c.type = 'dm'
|
||||
AND (SELECT COUNT(*) FROM conversation_members cm WHERE cm.conversation_id = c.id) = 1
|
||||
AND EXISTS (SELECT 1 FROM conversation_members cm2 WHERE cm2.conversation_id = c.id AND cm2.user_id = $1)
|
||||
LIMIT 1
|
||||
`, memberIDs[0]).Scan(&existingID)
|
||||
if err == nil {
|
||||
h.getByID(w, r, existingID)
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if len(memberIDs) == 2 {
|
||||
var existingID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT c.id FROM conversations c
|
||||
@@ -228,14 +247,14 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) buildResponse(ctx context.Context, convID string) (conversationResponse, error) {
|
||||
var resp conversationResponse
|
||||
err := h.db.QueryRowContext(ctx, `
|
||||
SELECT id, type, name, created_at::text FROM conversations WHERE id = $1
|
||||
SELECT id, type, COALESCE(name, ''), created_at::text FROM conversations WHERE id = $1
|
||||
`, convID).Scan(&resp.ID, &resp.Type, &resp.Name, &resp.CreatedAt)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
memberRows, err := h.db.QueryContext(ctx, `
|
||||
SELECT u.id, u.username, u.display_name, COALESCE(u.avatar, '')
|
||||
SELECT u.id, u.username, COALESCE(u.display_name, ''), COALESCE(u.avatar, '')
|
||||
FROM conversation_members cm
|
||||
JOIN users u ON u.id = cm.user_id
|
||||
WHERE cm.conversation_id = $1
|
||||
@@ -264,6 +283,7 @@ type messageResponse struct {
|
||||
Content string `json:"content"`
|
||||
EditedAt *string `json:"edited_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Reactions []emojiGroup `json:"reactions"`
|
||||
}
|
||||
|
||||
// ListMessages lists messages in a conversation.
|
||||
@@ -296,7 +316,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
FROM conversation_messages m
|
||||
JOIN users u ON u.id = m.author_id
|
||||
WHERE m.conversation_id = $1 AND m.created_at < (SELECT created_at FROM conversation_messages WHERE id = $2)
|
||||
ORDER BY m.created_at ASC
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3
|
||||
`, convID, before, limit)
|
||||
} else {
|
||||
@@ -305,7 +325,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
FROM conversation_messages m
|
||||
JOIN users u ON u.id = m.author_id
|
||||
WHERE m.conversation_id = $1
|
||||
ORDER BY m.created_at ASC
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $2
|
||||
`, convID, limit)
|
||||
}
|
||||
@@ -334,6 +354,13 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
messages = h.attachReactions(r.Context(), messages)
|
||||
|
||||
// Reverse: query returns DESC (newest first), client expects ASC (oldest first).
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
@@ -390,6 +417,8 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
msg.DisplayName = &displayName.String
|
||||
}
|
||||
|
||||
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
||||
|
||||
if h.hub != nil {
|
||||
h.hub.BroadcastToConversation(convID, gateway.Event{
|
||||
Type: gateway.EventMessageCreate,
|
||||
@@ -397,6 +426,26 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Push notifications for DM participants
|
||||
if h.pushHandler != nil {
|
||||
go func() {
|
||||
members, err := h.MemberIDs(context.Background(), convID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"title": msg.AuthorName,
|
||||
"body": req.Content,
|
||||
"url": "/conversations/" + convID,
|
||||
}
|
||||
for _, memberID := range members {
|
||||
if memberID != userID {
|
||||
h.pushHandler.SendPush(context.Background(), memberID, payload)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(msg)
|
||||
@@ -424,3 +473,185 @@ func (h *Handler) MemberIDs(ctx context.Context, convID string) ([]string, error
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
type emojiGroup struct {
|
||||
Emoji string `json:"emoji"`
|
||||
Count int `json:"count"`
|
||||
Users []string `json:"users"`
|
||||
Details []reactionResponse `json:"details"`
|
||||
}
|
||||
|
||||
func (h *Handler) attachReactions(ctx context.Context, messages []messageResponse) []messageResponse {
|
||||
if len(messages) == 0 {
|
||||
return messages
|
||||
}
|
||||
|
||||
msgIDs := make([]string, len(messages))
|
||||
msgMap := make(map[string]int)
|
||||
for i, msg := range messages {
|
||||
msgIDs[i] = msg.ID
|
||||
msgMap[msg.ID] = i
|
||||
messages[i].Reactions = make([]emojiGroup, 0)
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(msgIDs))
|
||||
args := make([]interface{}, len(msgIDs))
|
||||
for i, id := range msgIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, message_id, user_id, emoji, created_at::text
|
||||
FROM conversation_reactions
|
||||
WHERE message_id IN (%s)
|
||||
ORDER BY created_at ASC
|
||||
`, strings.Join(placeholders, ", "))
|
||||
|
||||
rows, err := h.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return messages
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
reactionsMap := make(map[string]map[string]*emojiGroup)
|
||||
emojiOrderMap := make(map[string][]string)
|
||||
|
||||
for rows.Next() {
|
||||
var reaction reactionResponse
|
||||
var createdAt sql.NullString
|
||||
if err := rows.Scan(&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
reaction.CreatedAt = createdAt.String
|
||||
|
||||
msgID := reaction.MessageID
|
||||
if _, ok := reactionsMap[msgID]; !ok {
|
||||
reactionsMap[msgID] = make(map[string]*emojiGroup)
|
||||
emojiOrderMap[msgID] = make([]string, 0)
|
||||
}
|
||||
|
||||
group, ok := reactionsMap[msgID][reaction.Emoji]
|
||||
if !ok {
|
||||
group = &emojiGroup{
|
||||
Emoji: reaction.Emoji,
|
||||
Users: []string{},
|
||||
Details: []reactionResponse{},
|
||||
}
|
||||
reactionsMap[msgID][reaction.Emoji] = group
|
||||
emojiOrderMap[msgID] = append(emojiOrderMap[msgID], reaction.Emoji)
|
||||
}
|
||||
group.Count++
|
||||
group.Users = append(group.Users, reaction.UserID)
|
||||
group.Details = append(group.Details, reaction)
|
||||
}
|
||||
|
||||
for msgID, emojisMap := range reactionsMap {
|
||||
idx, ok := msgMap[msgID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
order := emojiOrderMap[msgID]
|
||||
for _, emoji := range order {
|
||||
messages[idx].Reactions = append(messages[idx].Reactions, *emojisMap[emoji])
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
type reactionResponse struct {
|
||||
ID string `json:"id"`
|
||||
MessageID string `json:"message_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Emoji string `json:"emoji"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// @Summary Add a reaction to a DM
|
||||
// @Router /conversations/{conversationID}/messages/{messageID}/reactions [post]
|
||||
func (h *Handler) AddReaction(w http.ResponseWriter, r *http.Request) {
|
||||
convID := chi.URLParam(r, "conversationID")
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok || !h.isMember(r.Context(), convID, userID) {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Emoji string `json:"emoji"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Emoji == "" {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var reaction reactionResponse
|
||||
reaction.ConversationID = convID
|
||||
var createdAt sql.NullString
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO conversation_reactions (message_id, user_id, emoji)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (message_id, user_id, emoji) DO UPDATE SET emoji = EXCLUDED.emoji
|
||||
RETURNING id, message_id, user_id, emoji, created_at::text
|
||||
`, messageID, userID, req.Emoji).Scan(
|
||||
&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to add dm reaction", "error", err)
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reaction.CreatedAt = createdAt.String
|
||||
|
||||
h.hub.BroadcastToConversation(convID, gateway.Event{
|
||||
Type: gateway.EventReactionAdd,
|
||||
Data: map[string]interface{}{
|
||||
"reaction": reaction,
|
||||
"conversation_id": convID,
|
||||
"message_id": messageID,
|
||||
},
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(reaction)
|
||||
}
|
||||
|
||||
// @Summary Remove a reaction from a DM
|
||||
// @Router /conversations/{conversationID}/messages/{messageID}/reactions/{emoji} [delete]
|
||||
func (h *Handler) RemoveReaction(w http.ResponseWriter, r *http.Request) {
|
||||
convID := chi.URLParam(r, "conversationID")
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
emoji := chi.URLParam(r, "emoji")
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok || !h.isMember(r.Context(), convID, userID) {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.db.ExecContext(r.Context(), `
|
||||
DELETE FROM conversation_reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3
|
||||
`, messageID, userID, emoji)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to remove dm reaction", "error", err)
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected > 0 {
|
||||
h.hub.BroadcastToConversation(convID, gateway.Event{
|
||||
Type: gateway.EventReactionRemove,
|
||||
Data: map[string]interface{}{
|
||||
"conversation_id": convID,
|
||||
"message_id": messageID,
|
||||
"user_id": userID,
|
||||
"emoji": emoji,
|
||||
},
|
||||
})
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||
)
|
||||
|
||||
type Mailer struct {
|
||||
cfg config.SMTPConfig
|
||||
}
|
||||
|
||||
func NewMailer(cfg config.SMTPConfig) *Mailer {
|
||||
return &Mailer{cfg: cfg}
|
||||
}
|
||||
|
||||
func (m *Mailer) Send(to, subject, body string) error {
|
||||
if m.cfg.Host == "" || m.cfg.Port == "" {
|
||||
return fmt.Errorf("SMTP is not configured")
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||
|
||||
msg := []byte("To: " + to + "\r\n" +
|
||||
"Subject: " + subject + "\r\n" +
|
||||
"Content-Type: text/html; charset=UTF-8\r\n" +
|
||||
"\r\n" +
|
||||
body + "\r\n")
|
||||
|
||||
addr := m.cfg.Host + ":" + m.cfg.Port
|
||||
|
||||
// Some servers (like STARTTLS on 587) require standard Dial, then StartTLS
|
||||
// We'll use smtp.SendMail which does STARTTLS automatically if the server supports it
|
||||
err := smtp.SendMail(addr, auth, m.cfg.Username, []string{to}, msg)
|
||||
if err != nil {
|
||||
// If standard SendMail fails due to TLS issues, fallback to manual TLS/STARTTLS if needed
|
||||
// But smtp.SendMail is usually sufficient for standard STARTTLS on port 587.
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mailer) SendVerificationEmail(to, username, token, appURL string) error {
|
||||
subject := "Verify your dumpsterChat email"
|
||||
link := fmt.Sprintf("%s/verify-email?token=%s", appURL, token)
|
||||
body := fmt.Sprintf(`
|
||||
<h1>Welcome to dumpsterChat, %s!</h1>
|
||||
<p>Please verify your email address by clicking the link below:</p>
|
||||
<p><a href="%s">%s</a></p>
|
||||
<p>If you did not sign up for this account, you can safely ignore this email.</p>
|
||||
`, username, link, link)
|
||||
|
||||
return m.Send(to, subject, body)
|
||||
}
|
||||
|
||||
func (m *Mailer) SendPasswordResetEmail(to, username, token, appURL string) error {
|
||||
subject := "Reset your dumpsterChat password"
|
||||
link := fmt.Sprintf("%s/reset-password?token=%s", appURL, token)
|
||||
body := fmt.Sprintf(`
|
||||
<h1>Password Reset Request</h1>
|
||||
<p>Hi %s,</p>
|
||||
<p>We received a request to reset your password. Click the link below to set a new password:</p>
|
||||
<p><a href="%s">%s</a></p>
|
||||
<p>If you did not request this, please ignore this email.</p>
|
||||
`, username, link, link)
|
||||
|
||||
return m.Send(to, subject, body)
|
||||
}
|
||||
|
||||
func (m *Mailer) SendAnnouncement(to, subject, body string) error {
|
||||
return m.Send(to, subject, body)
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -105,6 +107,10 @@ type Client struct {
|
||||
Hub *Hub
|
||||
Conn *websocket.Conn
|
||||
UserID string
|
||||
Username string
|
||||
IsBot bool
|
||||
BotID string
|
||||
BotName string
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
@@ -140,8 +146,32 @@ func (c *Client) readPump() {
|
||||
c.Hub.RecordActivity(c.UserID)
|
||||
|
||||
switch event.Type {
|
||||
case EventTypingStart, EventPresenceUpdate:
|
||||
case EventTypingStart:
|
||||
// Enrich with sender info so receivers know who is typing
|
||||
var data map[string]interface{}
|
||||
if raw, ok := event.Data.(json.RawMessage); ok {
|
||||
json.Unmarshal(raw, &data)
|
||||
}
|
||||
if data == nil {
|
||||
data = make(map[string]interface{})
|
||||
}
|
||||
data["user_id"] = c.UserID
|
||||
data["username"] = c.Username
|
||||
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: data})
|
||||
case EventPresenceUpdate:
|
||||
c.Hub.BroadcastEvent(event)
|
||||
case EventVoiceJoin, EventVoiceLeave, EventVoiceMute, EventVoiceDeafen:
|
||||
// Broadcast voice state changes to all clients with sender info
|
||||
var vData map[string]interface{}
|
||||
if raw, ok := event.Data.(json.RawMessage); ok {
|
||||
json.Unmarshal(raw, &vData)
|
||||
}
|
||||
if vData == nil {
|
||||
vData = make(map[string]interface{})
|
||||
}
|
||||
vData["user_id"] = c.UserID
|
||||
vData["username"] = c.Username
|
||||
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: vData})
|
||||
case EventVoiceWhisper:
|
||||
// Forward voice whispers only to the target user, not broadcast
|
||||
var whisperData struct {
|
||||
@@ -159,6 +189,18 @@ func (c *Client) readPump() {
|
||||
})
|
||||
}
|
||||
}
|
||||
case BotSendMessage:
|
||||
if !c.IsBot {
|
||||
c.Hub.logger.Warn("non-bot client sent SEND_MESSAGE", "user_id", c.UserID)
|
||||
continue
|
||||
}
|
||||
c.handleBotSendMessage(event.Data)
|
||||
case BotDeleteMessage:
|
||||
if !c.IsBot {
|
||||
c.Hub.logger.Warn("non-bot client sent DELETE_MESSAGE", "user_id", c.UserID)
|
||||
continue
|
||||
}
|
||||
c.handleBotDeleteMessage(event.Data)
|
||||
default:
|
||||
c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID)
|
||||
}
|
||||
@@ -221,40 +263,52 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
|
||||
// Try cookie-based auth first (browser clients).
|
||||
var token string
|
||||
if cookie, cookieErr := r.Cookie(cookieName); cookieErr == nil && cookie.Value != "" {
|
||||
token = cookie.Value
|
||||
var userID, username string
|
||||
|
||||
// Collect candidate tokens from Query param, Authorization header, and Cookies.
|
||||
var candidateTokens []string
|
||||
if qToken := r.URL.Query().Get("token"); qToken != "" {
|
||||
candidateTokens = append(candidateTokens, qToken)
|
||||
}
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
candidateTokens = append(candidateTokens, authHeader[7:])
|
||||
}
|
||||
for _, c := range r.Cookies() {
|
||||
if c.Name == cookieName && c.Value != "" {
|
||||
candidateTokens = append(candidateTokens, c.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to message-frame auth (TUI, bots).
|
||||
if token == "" {
|
||||
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
// Test candidates against Postgres sessions (tokens are stored hashed)
|
||||
for _, token := range candidateTokens {
|
||||
err := db.QueryRowContext(context.Background(),
|
||||
`SELECT u.id, u.username FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > NOW()`,
|
||||
hashToken(token),
|
||||
).Scan(&userID, &username)
|
||||
if err == nil && userID != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to message-frame auth if no candidate token authenticated (wait up to 5s)
|
||||
if userID == "" {
|
||||
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
_, raw, readErr := conn.ReadMessage()
|
||||
if readErr != nil {
|
||||
logger.Warn("ws auth: failed to read auth message", "error", readErr)
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"auth timeout"}`))
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
if readErr == nil {
|
||||
var auth authMessage
|
||||
if jsonErr := json.Unmarshal(raw, &auth); jsonErr != nil || auth.Token == "" {
|
||||
logger.Warn("ws auth: invalid auth message")
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"missing token"}`))
|
||||
conn.Close()
|
||||
return
|
||||
if jsonErr := json.Unmarshal(raw, &auth); jsonErr == nil && auth.Token != "" {
|
||||
_ = db.QueryRowContext(context.Background(),
|
||||
`SELECT u.id, u.username FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > NOW()`,
|
||||
hashToken(auth.Token),
|
||||
).Scan(&userID, &username)
|
||||
}
|
||||
}
|
||||
token = auth.Token
|
||||
}
|
||||
|
||||
var userID string
|
||||
err = db.QueryRowContext(context.Background(),
|
||||
`SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()`,
|
||||
token,
|
||||
).Scan(&userID)
|
||||
if err != nil {
|
||||
logger.Warn("ws auth: invalid session", "error", err)
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid session"}`))
|
||||
if userID == "" {
|
||||
logger.Warn("ws auth: no valid session found")
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"error","error":"invalid_session"}`))
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
@@ -266,6 +320,7 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
||||
Hub: hub,
|
||||
Conn: conn,
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
@@ -274,3 +329,195 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
// ServeBotWS handles websocket requests from bot clients.
|
||||
// Authenticates via ?token= query param (bot token, hashed lookup).
|
||||
func ServeBotWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
http.Error(w, `{"error":"token query param required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash the token and look up the bot.
|
||||
tokenHash := hashToken(token)
|
||||
|
||||
var botID, botName, ownerID string
|
||||
err := db.QueryRowContext(r.Context(),
|
||||
`SELECT id, name, owner_id FROM bots WHERE token = $1`,
|
||||
tokenHash,
|
||||
).Scan(&botID, &botName, &ownerID)
|
||||
if err != nil {
|
||||
logger.Warn("bot ws auth: invalid token")
|
||||
http.Error(w, `{"error":"invalid bot token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the bot is added to at least one server.
|
||||
var serverCount int
|
||||
err = db.QueryRowContext(r.Context(),
|
||||
`SELECT COUNT(*) FROM bot_servers WHERE bot_id = $1`, botID,
|
||||
).Scan(&serverCount)
|
||||
if err != nil || serverCount == 0 {
|
||||
logger.Warn("bot ws auth: bot not added to any server", "bot_id", botID)
|
||||
http.Error(w, `{"error":"bot not added to any server"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
logger.Error("bot websocket upgrade failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Load bot server memberships into hub so BroadcastToServer works.
|
||||
hub.RefreshUserServers(ownerID)
|
||||
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ready","bot_id":"`+botID+`"}`))
|
||||
|
||||
client := &Client{
|
||||
Hub: hub,
|
||||
Conn: conn,
|
||||
UserID: ownerID,
|
||||
Username: botName,
|
||||
IsBot: true,
|
||||
BotID: botID,
|
||||
BotName: botName,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
hub.Register(client)
|
||||
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
// hashToken returns the SHA-256 hex digest of a token.
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// handleBotSendMessage processes a SEND_MESSAGE action from a bot client.
|
||||
func (c *Client) handleBotSendMessage(data interface{}) {
|
||||
var payload struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
raw, ok := data.(json.RawMessage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || payload.ChannelID == "" || payload.Content == "" {
|
||||
c.Hub.logger.Warn("bot SEND_MESSAGE: invalid payload")
|
||||
return
|
||||
}
|
||||
if len(payload.Content) > 4000 {
|
||||
payload.Content = payload.Content[:4000]
|
||||
}
|
||||
|
||||
// Verify the bot is in the server that owns this channel.
|
||||
serverID, err := c.Hub.ServerIDForChannel(context.Background(), payload.ChannelID)
|
||||
if err != nil {
|
||||
c.Hub.logger.Warn("bot SEND_MESSAGE: channel not found", "channel_id", payload.ChannelID)
|
||||
return
|
||||
}
|
||||
var inServer bool
|
||||
err = c.Hub.db.QueryRowContext(context.Background(),
|
||||
`SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`,
|
||||
c.BotID, serverID,
|
||||
).Scan(&inServer)
|
||||
if err != nil || !inServer {
|
||||
c.Hub.logger.Warn("bot SEND_MESSAGE: bot not in server", "bot_id", c.BotID, "server_id", serverID)
|
||||
return
|
||||
}
|
||||
|
||||
// Insert the message with bot_id set.
|
||||
var msgID, createdAt string
|
||||
err = c.Hub.db.QueryRowContext(context.Background(),
|
||||
`INSERT INTO messages (channel_id, author_id, content, bot_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, created_at::text`,
|
||||
payload.ChannelID, c.UserID, payload.Content, c.BotID,
|
||||
).Scan(&msgID, &createdAt)
|
||||
if err != nil {
|
||||
c.Hub.logger.Error("bot SEND_MESSAGE: insert failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast MESSAGE_CREATE to the server.
|
||||
c.Hub.BroadcastToServer(serverID, Event{
|
||||
Type: EventMessageCreate,
|
||||
Data: map[string]interface{}{
|
||||
"id": msgID,
|
||||
"channel_id": payload.ChannelID,
|
||||
"author_id": c.UserID,
|
||||
"author_username": c.BotName,
|
||||
"author_display_name": nil,
|
||||
"author_bot": true,
|
||||
"bot_id": c.BotID,
|
||||
"bot_name": c.BotName,
|
||||
"content": payload.Content,
|
||||
"reply_to": nil,
|
||||
"edited_at": nil,
|
||||
"pinned": false,
|
||||
"created_at": createdAt,
|
||||
"embeds": []interface{}{},
|
||||
"reactions": []interface{}{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleBotDeleteMessage processes a DELETE_MESSAGE action from a bot client.
|
||||
func (c *Client) handleBotDeleteMessage(data interface{}) {
|
||||
var payload struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
}
|
||||
raw, ok := data.(json.RawMessage)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || payload.ChannelID == "" || payload.MessageID == "" {
|
||||
c.Hub.logger.Warn("bot DELETE_MESSAGE: invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the bot is in the server that owns this channel.
|
||||
serverID, err := c.Hub.ServerIDForChannel(context.Background(), payload.ChannelID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var inServer bool
|
||||
err = c.Hub.db.QueryRowContext(context.Background(),
|
||||
`SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`,
|
||||
c.BotID, serverID,
|
||||
).Scan(&inServer)
|
||||
if err != nil || !inServer {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the message (only if it exists in this channel).
|
||||
result, err := c.Hub.db.ExecContext(context.Background(),
|
||||
`DELETE FROM messages WHERE id = $1 AND channel_id = $2`,
|
||||
payload.MessageID, payload.ChannelID,
|
||||
)
|
||||
if err != nil {
|
||||
c.Hub.logger.Error("bot DELETE_MESSAGE: delete failed", "error", err)
|
||||
return
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast MESSAGE_DELETE.
|
||||
c.Hub.BroadcastToServer(serverID, Event{
|
||||
Type: EventMessageDelete,
|
||||
Data: map[string]string{
|
||||
"id": payload.MessageID,
|
||||
"channel_id": payload.ChannelID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ const (
|
||||
EventVoiceMute = "VOICE_MUTE"
|
||||
EventVoiceDeafen = "VOICE_DEAFEN"
|
||||
EventVoiceWhisper = "VOICE_WHISPER"
|
||||
|
||||
// Bot action events (sent by bot clients)
|
||||
BotSendMessage = "SEND_MESSAGE"
|
||||
BotDeleteMessage = "DELETE_MESSAGE"
|
||||
)
|
||||
|
||||
// Event represents a WebSocket event sent to clients.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -285,15 +286,30 @@ func (h *Hub) BroadcastToServer(serverID string, event Event) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := h.db.QueryContext(ctx, `SELECT user_id FROM members WHERE server_id = $1`, serverID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to load server members", "server_id", serverID, "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err == nil {
|
||||
members[strings.ToLower(strings.TrimSpace(uid))] = true
|
||||
}
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for client := range h.clients {
|
||||
servers, ok := h.userServers[client.UserID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, member := servers[serverID]; member {
|
||||
cID := strings.ToLower(strings.TrimSpace(client.UserID))
|
||||
if members[cID] {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
@@ -322,18 +338,19 @@ func (h *Hub) BroadcastToConversation(convID string, event Event) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := make(map[string]struct{})
|
||||
members := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err == nil {
|
||||
members[uid] = struct{}{}
|
||||
members[strings.ToLower(strings.TrimSpace(uid))] = true
|
||||
}
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for client := range h.clients {
|
||||
if _, ok := members[client.UserID]; ok {
|
||||
cID := strings.ToLower(strings.TrimSpace(client.UserID))
|
||||
if members[cID] {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type FeatureRequestHandler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewFeatureRequestHandler(db *sql.DB) *FeatureRequestHandler {
|
||||
return &FeatureRequestHandler{db: db}
|
||||
}
|
||||
|
||||
func (h *FeatureRequestHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/", h.List)
|
||||
r.Post("/", h.Create)
|
||||
r.Post("/{id}/vote", h.Vote)
|
||||
r.Delete("/{id}/vote", h.Unvote)
|
||||
r.Patch("/{id}/status", h.UpdateStatus)
|
||||
}
|
||||
|
||||
type featureRequestResponse struct {
|
||||
ID string `json:"id"`
|
||||
ServerID string `json:"server_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Voters []string `json:"voters"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (h *FeatureRequestHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
if serverID == "" {
|
||||
http.Error(w, `{"error":"serverID required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT fr.id, fr.server_id, fr.channel_id, fr.author_id, fr.title, fr.description, fr.status,
|
||||
fr.created_at, fr.updated_at,
|
||||
COALESCE((SELECT count(*) FROM feature_request_votes frv WHERE frv.feature_request_id = fr.id), 0) as vote_count
|
||||
FROM feature_requests fr
|
||||
WHERE fr.server_id = $1
|
||||
ORDER BY vote_count DESC, fr.created_at DESC
|
||||
`, serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"query failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []featureRequestResponse
|
||||
for rows.Next() {
|
||||
var fr featureRequestResponse
|
||||
if err := rows.Scan(&fr.ID, &fr.ServerID, &fr.ChannelID, &fr.AuthorID, &fr.Title, &fr.Description, &fr.Status, &fr.CreatedAt, &fr.UpdatedAt, &fr.VoteCount); err != nil {
|
||||
continue
|
||||
}
|
||||
// Fetch voters
|
||||
voterRows, _ := h.db.QueryContext(r.Context(), `
|
||||
SELECT user_id FROM feature_request_votes WHERE feature_request_id = $1
|
||||
`, fr.ID)
|
||||
if voterRows != nil {
|
||||
for voterRows.Next() {
|
||||
var vid string
|
||||
if voterRows.Scan(&vid) == nil {
|
||||
fr.Voters = append(fr.Voters, vid)
|
||||
}
|
||||
}
|
||||
voterRows.Close()
|
||||
}
|
||||
if fr.Voters == nil {
|
||||
fr.Voters = []string{}
|
||||
}
|
||||
results = append(results, fr)
|
||||
}
|
||||
if results == nil {
|
||||
results = []featureRequestResponse{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(results)
|
||||
}
|
||||
|
||||
type createFeatureRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
}
|
||||
|
||||
func (h *FeatureRequestHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
if serverID == "" {
|
||||
http.Error(w, `{"error":"serverID required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req createFeatureRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Title == "" {
|
||||
http.Error(w, `{"error":"title required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.ChannelID == "" {
|
||||
// Default to the first text channel in the server
|
||||
h.db.QueryRowContext(r.Context(), `
|
||||
SELECT id FROM channels WHERE server_id = $1 AND type = 'text' ORDER BY position LIMIT 1
|
||||
`, serverID).Scan(&req.ChannelID)
|
||||
}
|
||||
|
||||
var fr featureRequestResponse
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO feature_requests (server_id, channel_id, author_id, title, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, server_id, channel_id, author_id, title, description, status, created_at, updated_at
|
||||
`, serverID, req.ChannelID, userID, req.Title, req.Description).Scan(
|
||||
&fr.ID, &fr.ServerID, &fr.ChannelID, &fr.AuthorID, &fr.Title, &fr.Description, &fr.Status, &fr.CreatedAt, &fr.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create feature request"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fr.VoteCount = 0
|
||||
fr.Voters = []string{}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(fr)
|
||||
}
|
||||
|
||||
func (h *FeatureRequestHandler) Vote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
_, err := h.db.ExecContext(r.Context(), `
|
||||
INSERT INTO feature_request_votes (feature_request_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
|
||||
`, id, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"vote failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
func (h *FeatureRequestHandler) Unvote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
h.db.ExecContext(r.Context(), `
|
||||
DELETE FROM feature_request_votes WHERE feature_request_id = $1 AND user_id = $2
|
||||
`, id, userID)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
func (h *FeatureRequestHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
validStatuses := map[string]bool{"open": true, "planned": true, "done": true, "rejected": true}
|
||||
if !validStatuses[req.Status] {
|
||||
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.db.ExecContext(r.Context(), `
|
||||
UPDATE feature_requests SET status = $1, updated_at = NOW() WHERE id = $2
|
||||
`, req.Status, id)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
@@ -27,6 +27,13 @@ type Handler struct {
|
||||
pushHandler *push.Handler
|
||||
logger *slog.Logger
|
||||
checker *permissions.Checker
|
||||
// Optional: intercepts /confess so the original message is never stored/broadcast.
|
||||
confess ConfessHandler
|
||||
}
|
||||
|
||||
// ConfessHandler posts an anonymous confession and returns the bot message payload.
|
||||
type ConfessHandler interface {
|
||||
TryConfess(ctx context.Context, serverID, authorID, content string) (payload map[string]interface{}, handled bool)
|
||||
}
|
||||
|
||||
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger, checker *permissions.Checker) *Handler {
|
||||
@@ -40,6 +47,11 @@ func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger
|
||||
}
|
||||
}
|
||||
|
||||
// SetConfessHandler wires the built-in confess interceptor (optional).
|
||||
func (h *Handler) SetConfessHandler(c ConfessHandler) {
|
||||
h.confess = c
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/", h.List)
|
||||
r.Post("/", h.Create)
|
||||
@@ -47,6 +59,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/bulk-delete", h.BulkDelete)
|
||||
r.Patch("/{messageID}", h.Update)
|
||||
r.Delete("/{messageID}", h.Delete)
|
||||
r.Get("/pinned", h.ListPinned)
|
||||
r.Put("/{messageID}/pin", h.Pin)
|
||||
r.Delete("/{messageID}/pin", h.Unpin)
|
||||
}
|
||||
|
||||
type bulkDeleteRequest struct {
|
||||
@@ -116,6 +131,7 @@ func (h *Handler) BulkDelete(w http.ResponseWriter, r *http.Request) {
|
||||
Type: gateway.EventMessageDelete,
|
||||
Data: map[string]string{
|
||||
"id": id,
|
||||
"message_id": id,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
})
|
||||
@@ -135,17 +151,38 @@ type embedResponse struct {
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
type reactionResponse struct {
|
||||
ID string `json:"id"`
|
||||
MessageID string `json:"message_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Emoji string `json:"emoji"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type emojiGroup struct {
|
||||
Emoji string `json:"emoji"`
|
||||
Count int `json:"count"`
|
||||
Users []string `json:"users"`
|
||||
Details []reactionResponse `json:"details"`
|
||||
}
|
||||
|
||||
type messageResponse struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
AuthorID string `json:"author_id"`
|
||||
AuthorName string `json:"author_username"`
|
||||
DisplayName *string `json:"author_display_name"`
|
||||
AuthorBot bool `json:"author_bot"`
|
||||
BotID *string `json:"bot_id,omitempty"`
|
||||
BotName *string `json:"bot_name,omitempty"`
|
||||
Content string `json:"content"`
|
||||
ReplyTo *string `json:"reply_to,omitempty"`
|
||||
EditedAt *string `json:"edited_at"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Embeds []embedResponse `json:"embeds"`
|
||||
Reactions []emojiGroup `json:"reactions"`
|
||||
Poll *pollResponse `json:"poll,omitempty"`
|
||||
}
|
||||
|
||||
// isMember checks whether the given user is a member of the given server.
|
||||
@@ -219,6 +256,29 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Gate @everyone / @channel on MENTION_EVERYONE (owner/admin always pass).
|
||||
if hasBroadcastToken(req.Content, "everyone") || hasBroadcastToken(req.Content, "channel") {
|
||||
allowed, permErr := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MENTION_EVERYONE)
|
||||
if permErr != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, `{"error":"missing permission: MENTION_EVERYONE"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous confessions: never store/broadcast the original /confess message.
|
||||
if h.confess != nil {
|
||||
if payload, handled := h.confess.TryConfess(r.Context(), serverID, userID, req.Content); handled {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(payload)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
@@ -229,9 +289,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO messages (channel_id, author_id, content, reply_to)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
|
||||
`, channelID, userID, req.Content, replyTo).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
|
||||
@@ -254,6 +314,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
msg.Reactions = make([]emojiGroup, 0)
|
||||
|
||||
// Broadcast MESSAGE_CREATE event via WebSocket (scoped to server)
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
@@ -355,9 +416,9 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE messages SET content = $1, edited_at = NOW()
|
||||
WHERE id = $2 AND author_id = $3
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
|
||||
`, req.Content, messageID, userID).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -386,6 +447,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Re-fetch embeds after edit.
|
||||
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
|
||||
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
||||
|
||||
// Look up serverID for scoped broadcast
|
||||
serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID)
|
||||
@@ -441,6 +503,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
Type: gateway.EventMessageDelete,
|
||||
Data: map[string]string{
|
||||
"id": messageID,
|
||||
"message_id": messageID,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
})
|
||||
@@ -482,20 +545,22 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
if before != "" {
|
||||
rows, err = h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, m.bot_id, b.name
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
LEFT JOIN bots b ON m.bot_id = b.id
|
||||
WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
|
||||
ORDER BY m.created_at ASC
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3
|
||||
`, channelID, before, limit)
|
||||
} else {
|
||||
rows, err = h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, m.bot_id, b.name
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
LEFT JOIN bots b ON m.bot_id = b.id
|
||||
WHERE m.channel_id = $1
|
||||
ORDER BY m.created_at ASC
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $2
|
||||
`, channelID, limit)
|
||||
}
|
||||
@@ -511,7 +576,9 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt)
|
||||
var botID sql.NullString
|
||||
var botName sql.NullString
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &botID, &botName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -521,11 +588,25 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
if botID.Valid {
|
||||
msg.BotID = &botID.String
|
||||
msg.AuthorBot = true
|
||||
}
|
||||
if botName.Valid {
|
||||
msg.BotName = &botName.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
messages = h.attachEmbeds(r.Context(), messages)
|
||||
messages = h.attachReactions(r.Context(), messages)
|
||||
messages = h.attachPolls(r.Context(), messages)
|
||||
|
||||
// Reverse: query returns DESC (newest first), client expects ASC (oldest first).
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
@@ -565,6 +646,189 @@ func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse)
|
||||
return messages
|
||||
}
|
||||
|
||||
func (h *Handler) attachReactions(ctx context.Context, messages []messageResponse) []messageResponse {
|
||||
if len(messages) == 0 {
|
||||
return messages
|
||||
}
|
||||
|
||||
msgIDs := make([]string, len(messages))
|
||||
msgMap := make(map[string]int)
|
||||
for i, msg := range messages {
|
||||
msgIDs[i] = msg.ID
|
||||
msgMap[msg.ID] = i
|
||||
messages[i].Reactions = make([]emojiGroup, 0)
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(msgIDs))
|
||||
args := make([]interface{}, len(msgIDs))
|
||||
for i, id := range msgIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, message_id, user_id, emoji, created_at::text
|
||||
FROM reactions
|
||||
WHERE message_id IN (%s)
|
||||
ORDER BY created_at ASC
|
||||
`, strings.Join(placeholders, ", "))
|
||||
|
||||
rows, err := h.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return messages
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
reactionsMap := make(map[string]map[string]*emojiGroup)
|
||||
emojiOrderMap := make(map[string][]string)
|
||||
|
||||
for rows.Next() {
|
||||
var reaction reactionResponse
|
||||
var createdAt sql.NullString
|
||||
if err := rows.Scan(&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
reaction.CreatedAt = createdAt.String
|
||||
|
||||
msgID := reaction.MessageID
|
||||
if _, ok := reactionsMap[msgID]; !ok {
|
||||
reactionsMap[msgID] = make(map[string]*emojiGroup)
|
||||
emojiOrderMap[msgID] = make([]string, 0)
|
||||
}
|
||||
|
||||
group, ok := reactionsMap[msgID][reaction.Emoji]
|
||||
if !ok {
|
||||
group = &emojiGroup{
|
||||
Emoji: reaction.Emoji,
|
||||
Users: []string{},
|
||||
Details: []reactionResponse{},
|
||||
}
|
||||
reactionsMap[msgID][reaction.Emoji] = group
|
||||
emojiOrderMap[msgID] = append(emojiOrderMap[msgID], reaction.Emoji)
|
||||
}
|
||||
group.Count++
|
||||
group.Users = append(group.Users, reaction.UserID)
|
||||
group.Details = append(group.Details, reaction)
|
||||
}
|
||||
|
||||
for msgID, emojisMap := range reactionsMap {
|
||||
idx, ok := msgMap[msgID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
order := emojiOrderMap[msgID]
|
||||
for _, emoji := range order {
|
||||
messages[idx].Reactions = append(messages[idx].Reactions, *emojisMap[emoji])
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// attachPolls loads poll data for messages that have polls.
|
||||
func (h *Handler) attachPolls(ctx context.Context, messages []messageResponse) []messageResponse {
|
||||
if len(messages) == 0 {
|
||||
return messages
|
||||
}
|
||||
|
||||
msgIDs := make([]string, len(messages))
|
||||
for i, m := range messages {
|
||||
msgIDs[i] = m.ID
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(msgIDs))
|
||||
args := make([]interface{}, len(msgIDs))
|
||||
for i, id := range msgIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT p.id, p.message_id, p.question, p.created_at::text
|
||||
FROM polls p
|
||||
WHERE p.message_id IN (%s)
|
||||
`, strings.Join(placeholders, ", "))
|
||||
|
||||
rows, err := h.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return messages
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
msgMap := make(map[string]int)
|
||||
for i, m := range messages {
|
||||
msgMap[m.ID] = i
|
||||
}
|
||||
|
||||
type pollRow struct {
|
||||
ID string
|
||||
MessageID string
|
||||
Question string
|
||||
CreatedAt string
|
||||
}
|
||||
pollRows := make([]pollRow, 0)
|
||||
for rows.Next() {
|
||||
var p pollRow
|
||||
if err := rows.Scan(&p.ID, &p.MessageID, &p.Question, &p.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
pollRows = append(pollRows, p)
|
||||
}
|
||||
|
||||
for _, pr := range pollRows {
|
||||
idx, ok := msgMap[pr.MessageID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
poll := &pollResponse{
|
||||
ID: pr.ID,
|
||||
MessageID: pr.MessageID,
|
||||
Question: pr.Question,
|
||||
CreatedAt: pr.CreatedAt,
|
||||
Options: make([]pollOptionResponse, 0),
|
||||
}
|
||||
|
||||
optRows, err := h.db.QueryContext(ctx, `
|
||||
SELECT po.id, po.text, po.position, COUNT(pv.id) as votes
|
||||
FROM poll_options po
|
||||
LEFT JOIN poll_votes pv ON pv.option_id = po.id
|
||||
WHERE po.poll_id = $1
|
||||
GROUP BY po.id, po.text, po.position
|
||||
ORDER BY po.position
|
||||
`, pr.ID)
|
||||
if err == nil {
|
||||
for optRows.Next() {
|
||||
var opt pollOptionResponse
|
||||
if err := optRows.Scan(&opt.ID, &opt.Text, &opt.Position, &opt.Votes); err != nil {
|
||||
continue
|
||||
}
|
||||
opt.Voters = []string{}
|
||||
poll.Options = append(poll.Options, opt)
|
||||
}
|
||||
optRows.Close()
|
||||
|
||||
for i, opt := range poll.Options {
|
||||
voterRows, err := h.db.QueryContext(ctx, `SELECT user_id FROM poll_votes WHERE option_id = $1`, opt.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for voterRows.Next() {
|
||||
var voterID string
|
||||
if voterRows.Scan(&voterID) == nil {
|
||||
poll.Options[i].Voters = append(poll.Options[i].Voters, voterID)
|
||||
}
|
||||
}
|
||||
voterRows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
messages[idx].Poll = poll
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// Search searches messages in a channel using PostgreSQL full-text search.
|
||||
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
@@ -588,10 +852,11 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text,
|
||||
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text,
|
||||
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank, m.bot_id, b.name
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
LEFT JOIN bots b ON m.bot_id = b.id
|
||||
WHERE m.channel_id = $1 AND m.search_vector @@ plainto_tsquery('english', $2)
|
||||
ORDER BY rank DESC, m.created_at DESC
|
||||
LIMIT $3
|
||||
@@ -609,7 +874,9 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
var rank float64
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt, &rank)
|
||||
var botID sql.NullString
|
||||
var botName sql.NullString
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &rank, &botID, &botName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -619,11 +886,19 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
if botID.Valid {
|
||||
msg.BotID = &botID.String
|
||||
msg.AuthorBot = true
|
||||
}
|
||||
if botName.Valid {
|
||||
msg.BotName = &botName.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
messages = h.attachEmbeds(r.Context(), messages)
|
||||
messages = h.attachReactions(r.Context(), messages)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
@@ -668,3 +943,228 @@ func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, c
|
||||
|
||||
return userID, serverID, true
|
||||
}
|
||||
|
||||
func (h *Handler) Pin(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
_, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the message exists and belongs to this channel
|
||||
var msgChannelID string
|
||||
err := h.db.QueryRowContext(r.Context(), "SELECT channel_id FROM messages WHERE id = $1", messageID).Scan(&msgChannelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if msgChannelID != channelID {
|
||||
http.Error(w, `{"error":"message does not belong to this channel"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already pinned
|
||||
var isPinned bool
|
||||
err = h.db.QueryRowContext(r.Context(), "SELECT pinned FROM messages WHERE id = $1", messageID).Scan(&isPinned)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if isPinned {
|
||||
http.Error(w, `{"error":"message is already pinned"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Count pinned messages in this channel
|
||||
var count int
|
||||
err = h.db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND pinned = TRUE", channelID).Scan(&count)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if count >= 5 {
|
||||
http.Error(w, `{"error":"limit reached (max 5 pinned messages per channel)"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Update message
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE messages SET pinned = TRUE
|
||||
WHERE id = $1
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
|
||||
`, messageID).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to pin message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch author details
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT username, display_name FROM users WHERE id = $1
|
||||
`, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if replyTo.Valid {
|
||||
msg.ReplyTo = &replyTo.String
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
|
||||
// Attach embeds
|
||||
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
|
||||
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
||||
|
||||
// Broadcast MESSAGE_UPDATE
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageUpdate,
|
||||
Data: msg,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(msg)
|
||||
}
|
||||
|
||||
func (h *Handler) Unpin(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
_, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the message exists and belongs to this channel
|
||||
var msgChannelID string
|
||||
err := h.db.QueryRowContext(r.Context(), "SELECT channel_id FROM messages WHERE id = $1", messageID).Scan(&msgChannelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if msgChannelID != channelID {
|
||||
http.Error(w, `{"error":"message does not belong to this channel"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Update message
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE messages SET pinned = FALSE
|
||||
WHERE id = $1
|
||||
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
|
||||
`, messageID).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to unpin message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch author details
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT username, display_name FROM users WHERE id = $1
|
||||
`, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if replyTo.Valid {
|
||||
msg.ReplyTo = &replyTo.String
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
|
||||
// Attach embeds
|
||||
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
|
||||
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
||||
|
||||
// Broadcast MESSAGE_UPDATE
|
||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageUpdate,
|
||||
Data: msg,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(msg)
|
||||
}
|
||||
|
||||
func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
_, _, ok := h.requireChannelAccess(w, r, channelID, permissions.VIEW_CHANNEL)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, m.bot_id, b.name
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
LEFT JOIN bots b ON m.bot_id = b.id
|
||||
WHERE m.channel_id = $1 AND m.pinned = TRUE
|
||||
ORDER BY m.created_at DESC
|
||||
`, channelID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to list pinned messages"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
messages := make([]messageResponse, 0)
|
||||
for rows.Next() {
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
var botID sql.NullString
|
||||
var botName sql.NullString
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &botID, &botName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if replyTo.Valid {
|
||||
msg.ReplyTo = &replyTo.String
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
if botID.Valid {
|
||||
msg.BotID = &botID.String
|
||||
msg.AuthorBot = true
|
||||
}
|
||||
if botName.Valid {
|
||||
msg.BotName = &botName.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
messages = h.attachEmbeds(r.Context(), messages)
|
||||
messages = h.attachReactions(r.Context(), messages)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ import (
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||
)
|
||||
|
||||
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
|
||||
var everyoneMention = "@everyone"
|
||||
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
|
||||
var plainUsernameMention = regexp.MustCompile(`@([a-zA-Z0-9_.-]+)`)
|
||||
|
||||
// MentionHandler dispatches push notifications for @mentions.
|
||||
type MentionHandler struct {
|
||||
@@ -29,20 +30,40 @@ func NewMentionHandler(db *sql.DB, pushHandler *push.Handler, logger *slog.Logge
|
||||
}
|
||||
}
|
||||
|
||||
// hasBroadcastToken reports whether content contains @everyone / @channel as a whole token.
|
||||
func hasBroadcastToken(content, token string) bool {
|
||||
// token like "everyone" or "channel" (without @)
|
||||
needle := "@" + token
|
||||
idx := 0
|
||||
for {
|
||||
i := strings.Index(strings.ToLower(content[idx:]), needle)
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
i += idx
|
||||
end := i + len(needle)
|
||||
if end >= len(content) || !isUsernameChar(rune(content[end])) {
|
||||
return true
|
||||
}
|
||||
idx = end
|
||||
}
|
||||
}
|
||||
|
||||
func isUsernameChar(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' || r == '-'
|
||||
}
|
||||
|
||||
// ParseAndNotify parses message content for mentions and sends push notifications.
|
||||
func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) {
|
||||
// Find individual user mentions
|
||||
userMatches := mentionRegex.FindAllStringSubmatch(content, -1)
|
||||
mentionedUsers := make(map[string]bool)
|
||||
for _, match := range userMatches {
|
||||
|
||||
// Discord-style ID mentions
|
||||
for _, match := range mentionRegex.FindAllStringSubmatch(content, -1) {
|
||||
if len(match) > 1 {
|
||||
mentionedUsers[match[1]] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for @everyone
|
||||
isEveryone := strings.Contains(content, everyoneMention)
|
||||
|
||||
// Get channel info for notification
|
||||
var serverID, channelName string
|
||||
err := m.db.QueryRowContext(ctx,
|
||||
@@ -75,8 +96,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
"url": "/channels/" + channelID,
|
||||
}
|
||||
|
||||
if isEveryone {
|
||||
// Send to all server members except those who muted this channel
|
||||
// @everyone / @channel — fan out to server members (permission gated at create).
|
||||
// ponytail: both use the same fanout; UI labels differ. Split if channel-private members matter.
|
||||
if hasBroadcastToken(content, "everyone") || hasBroadcastToken(content, "channel") {
|
||||
rows, err := m.db.QueryContext(ctx,
|
||||
`SELECT m.user_id FROM members m
|
||||
LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3
|
||||
@@ -85,7 +107,7 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
serverID, authorID, channelID,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Error("failed to query server members for @everyone", "error", err)
|
||||
m.logger.Error("failed to query server members for broadcast mention", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -100,13 +122,43 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
return
|
||||
}
|
||||
|
||||
// Check for role mentions
|
||||
// Plain @username mentions (what the frontend actually stores)
|
||||
usernames := make([]string, 0)
|
||||
seenUsernames := make(map[string]bool)
|
||||
for _, match := range plainUsernameMention.FindAllStringSubmatch(content, -1) {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
u := strings.ToLower(match[1])
|
||||
if u == "everyone" || u == "channel" || u == "here" {
|
||||
continue
|
||||
}
|
||||
if !seenUsernames[u] {
|
||||
seenUsernames[u] = true
|
||||
usernames = append(usernames, match[1])
|
||||
}
|
||||
}
|
||||
if len(usernames) > 0 {
|
||||
// Resolve usernames that are members of this server.
|
||||
for _, uname := range usernames {
|
||||
var uid string
|
||||
err := m.db.QueryRowContext(ctx, `
|
||||
SELECT u.id FROM users u
|
||||
JOIN members m ON m.user_id = u.id
|
||||
WHERE m.server_id = $1 AND LOWER(u.username) = LOWER($2)
|
||||
`, serverID, uname).Scan(&uid)
|
||||
if err == nil {
|
||||
mentionedUsers[uid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Role mentions
|
||||
roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1)
|
||||
if len(roleMatches) > 0 {
|
||||
for _, match := range roleMatches {
|
||||
if len(match) > 1 {
|
||||
roleID := match[1]
|
||||
// Get users with this role
|
||||
rows, err := m.db.QueryContext(ctx,
|
||||
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
|
||||
roleID, authorID,
|
||||
@@ -127,12 +179,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the author from mentions
|
||||
delete(mentionedUsers, authorID)
|
||||
|
||||
// Send push to individually mentioned users
|
||||
for userID := range mentionedUsers {
|
||||
// Check if user is in DND status
|
||||
var status string
|
||||
err := m.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
|
||||
@@ -144,7 +193,6 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if user muted this channel
|
||||
var level string
|
||||
err = m.db.QueryRowContext(ctx,
|
||||
`SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package message
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasBroadcastToken(t *testing.T) {
|
||||
cases := []struct {
|
||||
content string
|
||||
token string
|
||||
want bool
|
||||
}{
|
||||
{"hello @everyone", "everyone", true},
|
||||
{"@everyone hi", "everyone", true},
|
||||
{"@EVERYONE", "everyone", true},
|
||||
{"@everyone!", "everyone", true},
|
||||
{"@everyoneelse", "everyone", false},
|
||||
{"noteveryone", "everyone", false},
|
||||
{"@channel", "channel", true},
|
||||
{"ping @channel please", "channel", true},
|
||||
{"@channeling", "channel", false},
|
||||
{"", "everyone", false},
|
||||
{"@", "everyone", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := hasBroadcastToken(tc.content, tc.token)
|
||||
if got != tc.want {
|
||||
t.Errorf("hasBroadcastToken(%q, %q) = %v, want %v", tc.content, tc.token, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUsernameChar(t *testing.T) {
|
||||
if !isUsernameChar('a') || !isUsernameChar('9') || !isUsernameChar('_') {
|
||||
t.Fatal("expected alnum/_")
|
||||
}
|
||||
if isUsernameChar(' ') || isUsernameChar('!') || isUsernameChar('@') {
|
||||
t.Fatal("unexpected username chars")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type PollHandler struct {
|
||||
db *sql.DB
|
||||
hub *gateway.Hub
|
||||
checker *permissions.Checker
|
||||
}
|
||||
|
||||
func NewPollHandler(db *sql.DB, hub *gateway.Hub, checker *permissions.Checker) *PollHandler {
|
||||
return &PollHandler{db: db, hub: hub, checker: checker}
|
||||
}
|
||||
|
||||
func (ph *PollHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/", ph.Create)
|
||||
r.Get("/{pollID}", ph.Get)
|
||||
r.Post("/{pollID}/vote", ph.Vote)
|
||||
}
|
||||
|
||||
type createPollRequest struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
|
||||
type pollOptionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Position int `json:"position"`
|
||||
Votes int `json:"votes"`
|
||||
Voters []string `json:"voters"`
|
||||
}
|
||||
|
||||
type pollResponse struct {
|
||||
ID string `json:"id"`
|
||||
MessageID string `json:"message_id"`
|
||||
Question string `json:"question"`
|
||||
Options []pollOptionResponse `json:"options"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (ph *PollHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req createPollRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Question == "" || len(req.Options) < 2 || len(req.Options) > 10 {
|
||||
http.Error(w, `{"error":"question required, 2-10 options"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var serverID string
|
||||
err := ph.db.QueryRowContext(r.Context(), `
|
||||
SELECT server_id FROM channels WHERE id = $1
|
||||
`, req.ChannelID).Scan(&serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := ph.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var msgID string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO messages (channel_id, author_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, req.ChannelID, userID, req.Question).Scan(&msgID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var pollID string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO polls (message_id, question)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id
|
||||
`, msgID, req.Question).Scan(&pollID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create poll"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
options := make([]pollOptionResponse, len(req.Options))
|
||||
for i, text := range req.Options {
|
||||
var optID string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO poll_options (poll_id, text, position)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, pollID, text, i).Scan(&optID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create option"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
options[i] = pollOptionResponse{
|
||||
ID: optID,
|
||||
Text: text,
|
||||
Position: i,
|
||||
Votes: 0,
|
||||
Voters: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var authorName string
|
||||
var displayName sql.NullString
|
||||
_ = ph.db.QueryRowContext(r.Context(), `
|
||||
SELECT username, display_name FROM users WHERE id = $1
|
||||
`, userID).Scan(&authorName, &displayName)
|
||||
|
||||
var createdAt string
|
||||
_ = ph.db.QueryRowContext(r.Context(), `SELECT created_at::text FROM messages WHERE id = $1`, msgID).Scan(&createdAt)
|
||||
|
||||
poll := pollResponse{
|
||||
ID: pollID,
|
||||
MessageID: msgID,
|
||||
Question: req.Question,
|
||||
Options: options,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
|
||||
broadcastMsg := map[string]interface{}{
|
||||
"id": msgID,
|
||||
"channel_id": req.ChannelID,
|
||||
"author_id": userID,
|
||||
"author_username": authorName,
|
||||
"author_display_name": displayName.String,
|
||||
"content": req.Question,
|
||||
"pinned": false,
|
||||
"created_at": createdAt,
|
||||
"reactions": []interface{}{},
|
||||
"embeds": []interface{}{},
|
||||
"poll": poll,
|
||||
}
|
||||
ph.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: gateway.EventMessageCreate,
|
||||
Data: broadcastMsg,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(poll)
|
||||
}
|
||||
|
||||
func (ph *PollHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
pollID := chi.URLParam(r, "pollID")
|
||||
|
||||
poll, err := ph.fetchPoll(r.Context(), pollID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"poll not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(poll)
|
||||
}
|
||||
|
||||
type voteRequest struct {
|
||||
OptionID string `json:"option_id"`
|
||||
}
|
||||
|
||||
func (ph *PollHandler) Vote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
pollID := chi.URLParam(r, "pollID")
|
||||
|
||||
var req voteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var optPollID string
|
||||
err := ph.db.QueryRowContext(r.Context(), `
|
||||
SELECT poll_id FROM poll_options WHERE id = $1
|
||||
`, req.OptionID).Scan(&optPollID)
|
||||
if err != nil || optPollID != pollID {
|
||||
http.Error(w, `{"error":"invalid option"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := ph.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(r.Context(), `
|
||||
DELETE FROM poll_votes
|
||||
WHERE user_id = $1
|
||||
AND option_id IN (SELECT id FROM poll_options WHERE poll_id = $2)
|
||||
`, userID, pollID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(r.Context(), `
|
||||
INSERT INTO poll_votes (option_id, user_id)
|
||||
VALUES ($1, $2)
|
||||
`, req.OptionID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get server for broadcast
|
||||
var msgID, channelID, serverID string
|
||||
_ = ph.db.QueryRowContext(r.Context(), `
|
||||
SELECT p.message_id, m.channel_id, c.server_id
|
||||
FROM polls p
|
||||
JOIN messages m ON m.id = p.message_id
|
||||
JOIN channels c ON c.id = m.channel_id
|
||||
WHERE p.id = $1
|
||||
`, pollID).Scan(&msgID, &channelID, &serverID)
|
||||
|
||||
pollResp, _ := ph.fetchPoll(r.Context(), pollID)
|
||||
if pollResp != nil {
|
||||
ph.hub.BroadcastToServer(serverID, gateway.Event{
|
||||
Type: "POLL_UPDATE",
|
||||
Data: map[string]interface{}{
|
||||
"poll": pollResp,
|
||||
"message_id": msgID,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (ph *PollHandler) fetchPoll(ctx context.Context, pollID string) (*pollResponse, error) {
|
||||
var poll pollResponse
|
||||
var createdAt sql.NullString
|
||||
err := ph.db.QueryRowContext(ctx, `
|
||||
SELECT id, message_id, question, created_at::text FROM polls WHERE id = $1
|
||||
`, pollID).Scan(&poll.ID, &poll.MessageID, &poll.Question, &createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if createdAt.Valid {
|
||||
poll.CreatedAt = createdAt.String
|
||||
}
|
||||
|
||||
rows, err := ph.db.QueryContext(ctx, `
|
||||
SELECT po.id, po.text, po.position, COUNT(pv.id) as votes
|
||||
FROM poll_options po
|
||||
LEFT JOIN poll_votes pv ON pv.option_id = po.id
|
||||
WHERE po.poll_id = $1
|
||||
GROUP BY po.id, po.text, po.position
|
||||
ORDER BY po.position
|
||||
`, pollID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
poll.Options = make([]pollOptionResponse, 0)
|
||||
for rows.Next() {
|
||||
var opt pollOptionResponse
|
||||
if err := rows.Scan(&opt.ID, &opt.Text, &opt.Position, &opt.Votes); err != nil {
|
||||
continue
|
||||
}
|
||||
opt.Voters = []string{}
|
||||
poll.Options = append(poll.Options, opt)
|
||||
}
|
||||
|
||||
for i, opt := range poll.Options {
|
||||
voterRows, err := ph.db.QueryContext(ctx, `SELECT user_id FROM poll_votes WHERE option_id = $1`, opt.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for voterRows.Next() {
|
||||
var voterID string
|
||||
if voterRows.Scan(&voterID) == nil {
|
||||
poll.Options[i].Voters = append(poll.Options[i].Voters, voterID)
|
||||
}
|
||||
}
|
||||
voterRows.Close()
|
||||
}
|
||||
|
||||
return &poll, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
@@ -12,16 +13,21 @@ import (
|
||||
type ipLimiter struct {
|
||||
mu sync.Mutex
|
||||
limiters map[string]*rate.Limiter
|
||||
lastUsed map[string]time.Time
|
||||
rate rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
|
||||
return &ipLimiter{
|
||||
l := &ipLimiter{
|
||||
limiters: make(map[string]*rate.Limiter),
|
||||
lastUsed: make(map[string]time.Time),
|
||||
rate: r,
|
||||
burst: burst,
|
||||
}
|
||||
// ponytail: evict inactive limiters every 5 minutes to prevent unbounded map growth
|
||||
go l.cleanup()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
||||
@@ -33,9 +39,27 @@ func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
|
||||
lim = rate.NewLimiter(l.rate, l.burst)
|
||||
l.limiters[ip] = lim
|
||||
}
|
||||
l.lastUsed[ip] = time.Now()
|
||||
return lim
|
||||
}
|
||||
|
||||
// cleanup runs in a background goroutine and removes limiters that haven't
|
||||
// been accessed in 10 minutes.
|
||||
func (l *ipLimiter) cleanup() {
|
||||
for {
|
||||
time.Sleep(5 * time.Minute)
|
||||
l.mu.Lock()
|
||||
now := time.Now()
|
||||
for ip, last := range l.lastUsed {
|
||||
if now.Sub(last) > 10*time.Minute {
|
||||
delete(l.limiters, ip)
|
||||
delete(l.lastUsed, ip)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimit returns middleware that limits requests per IP.
|
||||
// requestsPerSecond is the sustained rate, burst is the max burst size.
|
||||
func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler {
|
||||
|
||||
@@ -11,9 +11,15 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' https: data:; connect-src 'self' wss: ws:; font-src 'self';")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(self), geolocation=()")
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob: https://cdn.jsdelivr.net https://unpkg.com; "+
|
||||
"worker-src 'self' blob:; "+
|
||||
"child-src 'self' blob:; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' https: data: blob:; "+
|
||||
"connect-src 'self' wss: ws: https:; "+
|
||||
"font-src 'self';")
|
||||
w.Header().Set("Permissions-Policy", "camera=(self), microphone=(self), geolocation=()")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,13 +18,23 @@ type SessionStore interface {
|
||||
func Session(store SessionStore, cfg *config.Config) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var token string
|
||||
cookie, err := r.Cookie(cfg.Session.CookieName)
|
||||
if err != nil {
|
||||
if err == nil {
|
||||
token = cookie.Value
|
||||
} else {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
token = authHeader[7:]
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := store.GetUserIDByToken(r.Context(), cookie.Value)
|
||||
userID, err := store.GetUserIDByToken(r.Context(), token)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
|
||||
@@ -68,6 +68,13 @@ func (c *Checker) GetUserPermissions(ctx context.Context, serverID string, userI
|
||||
// CheckPermission reports whether the user has all the required permission bits
|
||||
// in the given server. ADMINISTRATOR bypasses all checks.
|
||||
func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID string, required int64) (bool, error) {
|
||||
// Check if the user is the server owner.
|
||||
var ownerID string
|
||||
err := c.db.QueryRowContext(ctx, `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
|
||||
if err == nil && ownerID == userID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
perms, err := c.GetUserPermissions(ctx, serverID, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -85,6 +92,13 @@ func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID s
|
||||
// for a specific channel. It layers channel-level overrides on top of server role
|
||||
// permissions. ADMINISTRATOR bypasses all checks.
|
||||
func (c *Checker) CheckChannelPermission(ctx context.Context, serverID, userID, channelID string, required int64) (bool, error) {
|
||||
// Check if the user is the server owner.
|
||||
var ownerID string
|
||||
err := c.db.QueryRowContext(ctx, `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
|
||||
if err == nil && ownerID == userID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
perms, err := c.GetUserPermissions(ctx, serverID, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -96,13 +110,14 @@ func (c *Checker) CheckChannelPermission(ctx context.Context, serverID, userID,
|
||||
}
|
||||
|
||||
// Layer channel overrides: apply deny first (subtract), then allow (add).
|
||||
// Role-based overrides from all user roles, then user-specific override.
|
||||
// Role-based overrides from all user roles, plus the @everyone role, then user-specific override.
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT co.allow_bitflags, co.deny_bitflags
|
||||
FROM channel_overrides co
|
||||
WHERE co.channel_id = $1 AND co.target_type = 'role'
|
||||
AND co.target_id IN (
|
||||
SELECT mr.role_id FROM member_roles mr WHERE mr.user_id = $2 AND mr.server_id = $3
|
||||
AND (
|
||||
co.target_id IN (SELECT mr.role_id FROM member_roles mr WHERE mr.user_id = $2 AND mr.server_id = $3)
|
||||
OR co.target_id = (SELECT id FROM roles WHERE server_id = $3 AND is_default = TRUE LIMIT 1)
|
||||
)
|
||||
`, channelID, userID, serverID)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package permissions
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHas(t *testing.T) {
|
||||
set := VIEW_CHANNEL | SEND_MESSAGES | MENTION_EVERYONE
|
||||
|
||||
if !Has(set, VIEW_CHANNEL) {
|
||||
t.Fatal("expected VIEW_CHANNEL")
|
||||
}
|
||||
if !Has(set, SEND_MESSAGES) {
|
||||
t.Fatal("expected SEND_MESSAGES")
|
||||
}
|
||||
if Has(set, KICK_MEMBERS) {
|
||||
t.Fatal("did not expect KICK_MEMBERS")
|
||||
}
|
||||
if !Has(set, VIEW_CHANNEL|SEND_MESSAGES) {
|
||||
t.Fatal("expected multi-bit all-present")
|
||||
}
|
||||
if Has(set, VIEW_CHANNEL|KICK_MEMBERS) {
|
||||
t.Fatal("multi-bit should require all bits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorBypassSemantics(t *testing.T) {
|
||||
// Client/backend convention: ADMINISTRATOR implies all gates when checked separately.
|
||||
if !Has(ADMINISTRATOR, ADMINISTRATOR) {
|
||||
t.Fatal("admin flag self")
|
||||
}
|
||||
// ADMINISTRATOR alone does not set other bits; Has is pure bit check.
|
||||
if Has(ADMINISTRATOR, KICK_MEMBERS) {
|
||||
t.Fatal("Has is not an admin-implies-all helper; CheckPermission does that")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultEveryoneDoesNotIncludeMentionEveryone(t *testing.T) {
|
||||
if Has(DefaultEveryonePermissions, MENTION_EVERYONE) {
|
||||
t.Fatal("@everyone default must not grant MENTION_EVERYONE")
|
||||
}
|
||||
if !Has(DefaultEveryonePermissions, SEND_MESSAGES) {
|
||||
t.Fatal("@everyone default should grant SEND_MESSAGES")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRemove(t *testing.T) {
|
||||
p := int64(0)
|
||||
p = Add(p, VIEW_CHANNEL)
|
||||
p = Add(p, KICK_MEMBERS)
|
||||
if !Has(p, VIEW_CHANNEL|KICK_MEMBERS) {
|
||||
t.Fatal("Add failed")
|
||||
}
|
||||
p = Remove(p, KICK_MEMBERS)
|
||||
if Has(p, KICK_MEMBERS) {
|
||||
t.Fatal("Remove failed")
|
||||
}
|
||||
if !Has(p, VIEW_CHANNEL) {
|
||||
t.Fatal("Remove cleared wrong bit")
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,11 @@ package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
@@ -31,10 +30,10 @@ func NewHandler(db *sql.DB, vapidPub, vapidPriv, vapidSubj string, logger *slog.
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r *http.ServeMux) {
|
||||
r.HandleFunc("POST /push/subscribe", h.Subscribe)
|
||||
r.HandleFunc("POST /push/unsubscribe", h.Unsubscribe)
|
||||
r.HandleFunc("GET /push/vapid-public-key", h.GetPublicKey)
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/push/subscribe", h.Subscribe)
|
||||
r.Post("/push/unsubscribe", h.Unsubscribe)
|
||||
r.Get("/push/vapid-public-key", h.GetPublicKey)
|
||||
}
|
||||
|
||||
// @Summary Get VAPID public key
|
||||
@@ -168,17 +167,22 @@ func (h *Handler) SendPush(ctx context.Context, userID string, payload map[strin
|
||||
Subscriber: h.vapidSubj,
|
||||
VAPIDPublicKey: h.vapidPub,
|
||||
VAPIDPrivateKey: h.vapidPriv,
|
||||
TTL: 30,
|
||||
TTL: 86400,
|
||||
Urgency: webpush.UrgencyHigh,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to send push", "error", err, "user_id", userID)
|
||||
h.logger.Error("failed to send push", "error", err, "user_id", userID, "endpoint", endpoint[:min(60, len(endpoint))])
|
||||
if resp != nil && resp.StatusCode == 410 {
|
||||
h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if resp != nil {
|
||||
h.logger.Info("push sent", "user_id", userID, "status", resp.StatusCode, "endpoint", endpoint[:min(60, len(endpoint))])
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 410 {
|
||||
h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,7 +190,7 @@ func (h *Handler) SendPush(ctx context.Context, userID string, payload map[strin
|
||||
// SendChannelNotification sends a push notification to all server members (except the author)
|
||||
// when a new message is posted in a channel.
|
||||
func (h *Handler) SendChannelNotification(ctx context.Context, channelID, authorID, channelName, content string) {
|
||||
if h.vapidPub == "" || h.vapidPriv == "" {
|
||||
if h.vapidPub == "" || h.vapidPriv == "" || h.db == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -197,6 +201,8 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("sending push notifications", "channel", channelName, "author", authorID)
|
||||
|
||||
body := content
|
||||
if len(body) > 200 {
|
||||
body = body[:200] + "..."
|
||||
@@ -222,6 +228,7 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sent := 0
|
||||
for rows.Next() {
|
||||
var userID, endpoint, p256dh, auth string
|
||||
if err := rows.Scan(&userID, &endpoint, &p256dh, &auth); err != nil {
|
||||
@@ -240,7 +247,8 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
|
||||
Subscriber: h.vapidSubj,
|
||||
VAPIDPublicKey: h.vapidPub,
|
||||
VAPIDPrivateKey: h.vapidPriv,
|
||||
TTL: 30,
|
||||
TTL: 86400,
|
||||
Urgency: webpush.UrgencyHigh,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to send push", "error", err, "user_id", userID)
|
||||
@@ -251,23 +259,12 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 300 {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
}
|
||||
h.logger.Info("push notifications sent", "count", sent, "channel", channelName)
|
||||
}
|
||||
|
||||
// GenerateVAPIDKeys generates a new VAPID key pair.
|
||||
func GenerateVAPIDKeys() (publicKey, privateKey string, err error) {
|
||||
privateKeyBytes := make([]byte, 32)
|
||||
_, err = rand.Read(privateKeyBytes)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
pubBytes := make([]byte, 65)
|
||||
copy(pubBytes, privateKeyBytes)
|
||||
|
||||
publicKey = base64.RawURLEncoding.EncodeToString(pubBytes)
|
||||
privateKey = base64.RawURLEncoding.EncodeToString(privateKeyBytes)
|
||||
|
||||
return publicKey, privateKey, nil
|
||||
}
|
||||
// GenerateVAPIDKeys is in cmd/keygen. Use github.com/SherClockHolmes/webpush-go directly.
|
||||
|
||||
@@ -21,9 +21,9 @@ func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/{messageID}/reactions", h.Add)
|
||||
r.Delete("/{messageID}/reactions/{emoji}", h.Remove)
|
||||
r.Get("/{messageID}/reactions", h.List)
|
||||
r.Post("/", h.Add)
|
||||
r.Delete("/{emoji}", h.Remove)
|
||||
r.Get("/", h.List)
|
||||
}
|
||||
|
||||
type addReactionRequest struct {
|
||||
|
||||
@@ -24,6 +24,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/{serverID}", h.Get)
|
||||
r.Patch("/{serverID}", h.Update)
|
||||
r.Delete("/{serverID}", h.Delete)
|
||||
r.Delete("/{serverID}/members/me", h.LeaveServer)
|
||||
}
|
||||
|
||||
type createServerRequest struct {
|
||||
@@ -315,3 +316,38 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) LeaveServer(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
|
||||
// Cannot leave a server you own
|
||||
var ownerID string
|
||||
err := h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if ownerID == userID {
|
||||
http.Error(w, `{"error":"server owner cannot leave; transfer ownership or delete the server"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.db.ExecContext(r.Context(), `DELETE FROM members WHERE user_id = $1 AND server_id = $2`, userID, serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to leave server"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
http.Error(w, `{"error":"not a member"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -6,28 +6,37 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type MemberHandler struct {
|
||||
db *sql.DB
|
||||
checker *permissions.Checker
|
||||
}
|
||||
|
||||
func NewMemberHandler(db *sql.DB) *MemberHandler {
|
||||
return &MemberHandler{db: db}
|
||||
return &MemberHandler{
|
||||
db: db,
|
||||
checker: permissions.NewChecker(db),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MemberHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/members", h.ListMembers)
|
||||
r.Patch("/members/{userID}", h.UpdateMember)
|
||||
}
|
||||
|
||||
type memberResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
IsBot bool `json:"is_bot,omitempty"`
|
||||
BotType string `json:"bot_type,omitempty"`
|
||||
}
|
||||
|
||||
func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -50,7 +59,7 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT u.id, u.username, u.display_name, COALESCE(u.avatar, ''), u.status, COALESCE(u.status_text, '')
|
||||
SELECT u.id, u.username, u.display_name, m.nickname, COALESCE(u.avatar, ''), u.status, COALESCE(u.status_text, '')
|
||||
FROM members m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.server_id = $1
|
||||
@@ -65,12 +74,104 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
members := make([]memberResponse, 0)
|
||||
for rows.Next() {
|
||||
var m memberResponse
|
||||
if err := rows.Scan(&m.ID, &m.Username, &m.DisplayName, &m.Avatar, &m.Status, &m.StatusText); err != nil {
|
||||
var nick sql.NullString
|
||||
if err := rows.Scan(&m.ID, &m.Username, &m.DisplayName, &nick, &m.Avatar, &m.Status, &m.StatusText); err != nil {
|
||||
continue
|
||||
}
|
||||
if nick.Valid {
|
||||
m.Nickname = &nick.String
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
|
||||
// Bots added to this server (separate BOTS section in the member list).
|
||||
botRows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT b.id, b.name, COALESCE(b.avatar, ''), COALESCE(b.bot_type, '')
|
||||
FROM bot_servers bs
|
||||
JOIN bots b ON b.id = bs.bot_id
|
||||
WHERE bs.server_id = $1
|
||||
ORDER BY b.name
|
||||
`, serverID)
|
||||
if err == nil {
|
||||
defer botRows.Close()
|
||||
for botRows.Next() {
|
||||
var m memberResponse
|
||||
if err := botRows.Scan(&m.ID, &m.Username, &m.Avatar, &m.BotType); err != nil {
|
||||
continue
|
||||
}
|
||||
m.DisplayName = m.Username
|
||||
m.Status = "online" // ponytail: no bot presence yet; always show online
|
||||
m.IsBot = true
|
||||
members = append(members, m)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(members)
|
||||
}
|
||||
|
||||
type updateMemberRequest struct {
|
||||
Nickname *string `json:"nickname"`
|
||||
}
|
||||
|
||||
func (h *MemberHandler) UpdateMember(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
targetUserID := chi.URLParam(r, "userID")
|
||||
|
||||
var req updateMemberRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Nickname == nil {
|
||||
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check permissions:
|
||||
// - If self, need CHANGE_NICKNAME
|
||||
// - If other, need MANAGE_NICKNAMES
|
||||
var requiredPerm int64
|
||||
if userID == targetUserID {
|
||||
requiredPerm = permissions.CHANGE_NICKNAME
|
||||
} else {
|
||||
requiredPerm = permissions.MANAGE_NICKNAMES
|
||||
}
|
||||
|
||||
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, requiredPerm)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Update nickname in the database
|
||||
var nickVal sql.NullString
|
||||
if *req.Nickname != "" {
|
||||
nickVal = sql.NullString{String: *req.Nickname, Valid: true}
|
||||
} else {
|
||||
nickVal = sql.NullString{Valid: false} // NULL value clears the nickname
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
UPDATE members
|
||||
SET nickname = $1
|
||||
WHERE user_id = $2 AND server_id = $3
|
||||
`, nickVal, targetUserID, serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to update nickname"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ type setMemberRolesRequest struct {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// requireManageServer checks that the caller has MANAGE_SERVER permission.
|
||||
// requireManageServer checks that the caller has MANAGE_SERVER or MANAGE_ROLES permission.
|
||||
// Returns false (and writes the error response) if denied.
|
||||
func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
@@ -89,11 +89,18 @@ func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request
|
||||
return false
|
||||
}
|
||||
|
||||
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_SERVER)
|
||||
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_ROLES)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
if !allowed {
|
||||
allowed, err = h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_SERVER)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return false
|
||||
@@ -358,12 +365,34 @@ func (h *RoleHandler) GetMemberRoles(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(roles)
|
||||
}
|
||||
|
||||
// SeedDefaultRole inserts the @everyone role for a newly created server.
|
||||
// SeedDefaultRole inserts the @everyone, Admin, and Moderator roles for a newly created server.
|
||||
// Call this after creating a server (e.g. from the server creation handler).
|
||||
func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error {
|
||||
// @everyone
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO roles (server_id, name, permissions, position, is_default)
|
||||
VALUES ($1, '@everyone', $2, 0, TRUE)
|
||||
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
||||
VALUES ($1, '@everyone', $2, 0, TRUE, NULL)
|
||||
`, serverID, permissions.DefaultEveryonePermissions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Admin
|
||||
adminPerms := permissions.ADMINISTRATOR
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
||||
VALUES ($1, 'Admin', $2, 99, FALSE, '#e06c75')
|
||||
`, serverID, adminPerms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Moderator
|
||||
modPerms := permissions.VIEW_CHANNEL | permissions.SEND_MESSAGES | permissions.MANAGE_MESSAGES | permissions.KICK_MEMBERS | permissions.BAN_MEMBERS | permissions.MUTE_MEMBERS | permissions.CONNECT_VOICE | permissions.SPEAK_VOICE
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
||||
VALUES ($1, 'Moderator', $2, 50, FALSE, '#61afef')
|
||||
`, serverID, modPerms)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -81,15 +82,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
// Check permission
|
||||
var hasPerm bool
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
JOIN member_roles mr ON mr.role_id = rp.role_id
|
||||
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
|
||||
)
|
||||
`, userID, serverID).Scan(&hasPerm)
|
||||
checker := permissions.NewChecker(h.db)
|
||||
hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||
if err != nil || !hasPerm {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
@@ -223,14 +217,8 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
var hasPerm bool
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
JOIN member_roles mr ON mr.role_id = rp.role_id
|
||||
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
|
||||
)
|
||||
`, userID, serverID).Scan(&hasPerm)
|
||||
checker := permissions.NewChecker(h.db)
|
||||
hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||
if err != nil || !hasPerm {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
@@ -279,14 +267,8 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if ownerID != userID {
|
||||
var hasPerm bool
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
JOIN member_roles mr ON mr.role_id = rp.role_id
|
||||
WHERE mr.user_id = $1 AND mr.server_id = $2 AND rp.permission = 'manage_channels'
|
||||
)
|
||||
`, userID, serverID).Scan(&hasPerm)
|
||||
checker := permissions.NewChecker(h.db)
|
||||
hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||
if err != nil || !hasPerm {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
const maxUploadSize = 25 << 20 // 25 MB per file
|
||||
const maxUploadSize = 1 << 30 // 1 GB per file
|
||||
|
||||
var allowedExtensions = map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||
@@ -119,7 +119,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"url":"/%s/%s","size":%d}`, h.bucket, objectName, info.Size)
|
||||
fmt.Fprintf(w, `{"url":"/files/%s","size":%d}`, objectName, info.Size)
|
||||
}
|
||||
|
||||
func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -153,6 +153,11 @@ func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", stat.ContentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size))
|
||||
// Inline display for images; attachment for everything else
|
||||
if strings.HasPrefix(stat.ContentType, "image/") {
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
} else {
|
||||
w.Header().Set("Content-Disposition", "attachment")
|
||||
}
|
||||
io.Copy(w, obj)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,33 @@ func (c *Client) ListParticipants(roomName string) ([]*livekit.ParticipantInfo,
|
||||
return resp.Participants, nil
|
||||
}
|
||||
|
||||
func (c *Client) MuteParticipant(roomName string, identity string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("voice client not configured")
|
||||
}
|
||||
|
||||
participant, err := c.roomClient.GetParticipant(context.Background(), &livekit.RoomParticipantIdentity{
|
||||
Room: roomName,
|
||||
Identity: identity,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get participant: %w", err)
|
||||
}
|
||||
|
||||
for _, track := range participant.Tracks {
|
||||
_, err := c.roomClient.MutePublishedTrack(context.Background(), &livekit.MuteRoomTrackRequest{
|
||||
Room: roomName,
|
||||
Identity: identity,
|
||||
TrackSid: track.Sid,
|
||||
Muted: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mute track %s: %w", track.Sid, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
@@ -15,6 +16,7 @@ type Handler struct {
|
||||
db *sql.DB
|
||||
client *Client
|
||||
hub *gateway.Hub
|
||||
checker *permissions.Checker
|
||||
}
|
||||
|
||||
func NewHandler(db *sql.DB, client *Client, hub *gateway.Hub) *Handler {
|
||||
@@ -22,12 +24,15 @@ func NewHandler(db *sql.DB, client *Client, hub *gateway.Hub) *Handler {
|
||||
db: db,
|
||||
client: client,
|
||||
hub: hub,
|
||||
checker: permissions.NewChecker(db),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth)
|
||||
r.Post("/token", h.GetToken)
|
||||
r.Get("/rooms/{roomID}/participants", h.GetParticipants)
|
||||
r.Post("/rooms/{roomID}/participants/{userID}/mute", h.MuteParticipant)
|
||||
}
|
||||
|
||||
type tokenRequest struct {
|
||||
@@ -148,3 +153,53 @@ func (h *Handler) GetParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// @Summary Mute a participant
|
||||
// @Description Mute a participant's tracks in a voice room (requires MUTE_MEMBERS permission)
|
||||
// @Tags voice
|
||||
// @Security SessionAuth
|
||||
// @Param roomID path string true "Room ID"
|
||||
// @Param userID path string true "User ID (Identity)"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /voice/rooms/{roomID}/participants/{userID}/mute [post]
|
||||
func (h *Handler) MuteParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
roomID := chi.URLParam(r, "roomID")
|
||||
targetIdentity := chi.URLParam(r, "userID")
|
||||
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the server_id for this channel to check permissions.
|
||||
var serverID string
|
||||
err := h.db.QueryRowContext(r.Context(), `SELECT server_id FROM channels WHERE id = $1`, roomID).Scan(&serverID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, `{"error":"room not found or not a server channel"}`, http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
hasPerm, err := h.checker.CheckChannelPermission(r.Context(), serverID, userID, roomID, permissions.MUTE_MEMBERS)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to check permissions"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !hasPerm {
|
||||
http.Error(w, `{"error":"forbidden: missing MUTE_MEMBERS permission"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.client.MuteParticipant(roomID, targetIdentity); err != nil {
|
||||
http.Error(w, `{"error":"failed to mute participant"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "629643353973",
|
||||
"project_id": "dumpster-chat",
|
||||
"storage_bucket": "dumpster-chat.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:629643353973:android:29dcc703959dd306c0fd3c",
|
||||
"android_client_info": {
|
||||
"package_name": "coffee.dustin.dumpster"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDt3h4G-imzD7IedRqYOUBIv8CtZQT2YyA"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -1,26 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="gruvbox">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<script>
|
||||
// Apply saved theme before paint (migrates legacy light/dark).
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('dumpster-theme');
|
||||
if (t === 'light') t = 'gruvbox-light';
|
||||
else if (t === 'dark' || !t) t = 'gruvbox';
|
||||
var ok = {
|
||||
gruvbox: 1,
|
||||
'gruvbox-light': 1,
|
||||
'one-dark': 1,
|
||||
dracula: 1,
|
||||
nord: 1,
|
||||
'tokyo-night': 1,
|
||||
catppuccin: 1,
|
||||
'solarized-dark': 1,
|
||||
monokai: 1,
|
||||
'github-dark': 1,
|
||||
};
|
||||
if (!ok[t]) t = 'gruvbox';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.classList.add(t === 'gruvbox-light' ? 'light' : 'dark');
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'gruvbox');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="description" content="A chaotic, self-hosted Discord-like platform" />
|
||||
|
||||
<!-- Apple -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Dumpster" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
|
||||
<!-- Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Splash screens for iOS (apple-touch-startup-image) -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<title>Dumpster</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
// Register service worker
|
||||
// Register service worker (only for web, avoid in Tauri to prevent 404 cache bugs)
|
||||
if ('serviceWorker' in navigator) {
|
||||
if (!('__TAURI_INTERNALS__' in window) && !('__TAURI__' in window)) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
} else {
|
||||
// In Tauri, aggressively unregister any old service workers that might be causing 404s
|
||||
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||
for (let registration of registrations) {
|
||||
registration.unregister();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
"name": "dumpster-web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.0",
|
||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||
"@livekit/components-react": "^2.9.21",
|
||||
"@livekit/track-processors": "^0.7.2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"emoji-picker-react": "^4.19.1",
|
||||
"livekit-client": "^2.20.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -18,6 +25,7 @@
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
@@ -745,6 +753,52 @@
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.0.tgz",
|
||||
"integrity": "sha512-X/vND0Y1l9fVJ9O79UgtZnXSpz4aNF3bXlDxiJAEAm6kgeSftp9wjjBPgqzazJV8YlmxfRoeXNfSCJ48sf/Hhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-svg-core": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.3.0.tgz",
|
||||
"integrity": "sha512-MFbTNLDWkLJwbozDvHOZ7hwyDjQcBMBattlcOQ6ZmV5YD9bBrqdl1rNtmVjQ/lzqveXXX3sMz2Ew6fAgXoxmkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.0.tgz",
|
||||
"integrity": "sha512-YxI/CuwWeI3nPIoYU//vkDS+3ige/67DPZ6XwMATpYEFESzO9L8zfJOKllGRgIlpT/uebrZCcvAzp3peD7GmTw==",
|
||||
"license": "(CC-BY-4.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/react-fontawesome": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.3.1.tgz",
|
||||
"integrity": "sha512-wGnAPhfzivDwBWYmEG8MSrEXPruoiMMo48NnsRkj1NZkoaawgOijPNAiSHKMYEoCsqTBSgLTzL6EqTTWGaUR4w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "~6 || ~7",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -856,6 +910,25 @@
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/track-processors": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.7.2.tgz",
|
||||
"integrity": "sha512-lzARBKTbBwqycdR/SwTu6//N0l20BzfDd7grxCXl07676SwRApNtZAK1GJjL1m3dCM3KBqH1aVxjMpNcbOw5uQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@mediapipe/tasks-vision": "0.10.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-transform": "^0.1.9",
|
||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.14",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -1299,6 +1372,236 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1360,6 +1663,23 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-transform": {
|
||||
"version": "0.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz",
|
||||
"integrity": "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/dom-webcodecs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dom-webcodecs": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
|
||||
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1399,6 +1719,16 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
|
||||
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1534,9 +1864,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.40",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
|
||||
"version": "2.10.42",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
|
||||
"integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1573,9 +1903,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
||||
"version": "4.28.5",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
|
||||
"integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1593,10 +1923,10 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"electron-to-chromium": "^1.5.376",
|
||||
"node-releases": "^2.0.48",
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"caniuse-lite": "^1.0.30001800",
|
||||
"electron-to-chromium": "^1.5.387",
|
||||
"node-releases": "^2.0.50",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1617,9 +1947,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001799",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
|
||||
"version": "1.0.30001802",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz",
|
||||
"integrity": "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1847,12 +2177,27 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.380",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
|
||||
"integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==",
|
||||
"version": "1.5.387",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
|
||||
"integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-picker-react": {
|
||||
"version": "4.19.1",
|
||||
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.19.1.tgz",
|
||||
"integrity": "sha512-BmDdqInKFVYJpv7qS9WI6L9656cDAC+FkDvUjJds56nKHbaVTBNeDmLwKBytRnzu37zWHs9Isg7gt5PT43y6xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flairup": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
@@ -2002,6 +2347,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/flairup": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
|
||||
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -4129,9 +4480,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4210,6 +4561,13 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
||||
@@ -7,10 +7,18 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.0",
|
||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||
"@livekit/components-react": "^2.9.21",
|
||||
"@livekit/track-processors": "^0.7.2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"emoji-picker-react": "^4.19.1",
|
||||
"livekit-client": "^2.20.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -20,6 +28,7 @@
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 791 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,22 +1,89 @@
|
||||
{
|
||||
"name": "Dumpster",
|
||||
"name": "Dumpster Chat",
|
||||
"short_name": "Dumpster",
|
||||
"description": "A chaotic, self-hosted Discord-like platform",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone"],
|
||||
"orientation": "any",
|
||||
"background_color": "#282828",
|
||||
"theme_color": "#282828",
|
||||
"orientation": "any",
|
||||
"categories": ["social", "communication"],
|
||||
"lang": "en",
|
||||
"dir": "ltr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192-maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Create Account",
|
||||
"short_name": "Register",
|
||||
"url": "/?register=true",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<title>Dumpster - Offline</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #282828;
|
||||
color: #ebdbb2;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1 {
|
||||
color: #fe8019;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
p {
|
||||
color: #a89984;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 1px solid #928374;
|
||||
color: #928374;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.status.online {
|
||||
border-color: #b8bb26;
|
||||
color: #b8bb26;
|
||||
}
|
||||
button {
|
||||
background: transparent;
|
||||
border: 1px solid #fe8019;
|
||||
color: #fe8019;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
button:hover {
|
||||
background: #fe8019;
|
||||
color: #282828;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">📡</div>
|
||||
<h1>YOU ARE OFFLINE</h1>
|
||||
<p>
|
||||
Dumpster can't reach the server. Check your connection and try again.
|
||||
Messages will sync when you're back online.
|
||||
</p>
|
||||
<div class="status" id="status">OFFLINE</div>
|
||||
<br />
|
||||
<button onclick="location.reload()">[RETRY]</button>
|
||||
</div>
|
||||
<script>
|
||||
function updateStatus() {
|
||||
const el = document.getElementById('status');
|
||||
if (navigator.onLine) {
|
||||
el.textContent = 'ONLINE';
|
||||
el.className = 'status online';
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
el.textContent = 'OFFLINE';
|
||||
el.className = 'status';
|
||||
}
|
||||
}
|
||||
window.addEventListener('online', updateStatus);
|
||||
window.addEventListener('offline', updateStatus);
|
||||
updateStatus();
|
||||
// Poll connectivity every 10s
|
||||
setInterval(() => {
|
||||
if (navigator.onLine) {
|
||||
fetch('/', { method: 'HEAD', cache: 'no-cache' })
|
||||
.then(() => location.reload())
|
||||
.catch(() => {});
|
||||
}
|
||||
}, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,95 +1,111 @@
|
||||
const CACHE_NAME = 'dumpster-v1';
|
||||
const STATIC_ASSETS = [
|
||||
// Dumpster PWA Service Worker
|
||||
// Bump CACHE_VERSION on each deploy to force re-cache of hashed assets.
|
||||
const CACHE_VERSION = 'dumpster-v6';
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
|
||||
// Assets to pre-cache on install (shell files)
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
'/offline.html',
|
||||
'/favicon.png',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
];
|
||||
|
||||
// Install: pre-cache shell and skip waiting
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
caches.open(CACHE_VERSION).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting())
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: clean old caches and claim clients
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
caches
|
||||
.keys()
|
||||
.then((names) => Promise.all(names.filter((n) => n !== CACHE_VERSION).map((n) => caches.delete(n))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch handler
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') return;
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip API requests
|
||||
if (event.request.url.includes('/api/')) return;
|
||||
// Skip API, WebSocket, and cross-origin requests
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return;
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Skip WebSocket
|
||||
if (event.request.url.includes('/ws')) return;
|
||||
// Navigation requests: network-first, offline fallback
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_VERSION).then((c) => c.put(event.request, clone));
|
||||
return response;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.match(event.request).then((cached) => cached || caches.match(OFFLINE_URL))
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets (JS, CSS, images, fonts): stale-while-revalidate
|
||||
// Serve cached immediately, fetch fresh in background
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
// Return cached version, fetch new one in background
|
||||
const fetchPromise = fetch(event.request).then((response) => {
|
||||
// Only cache successful responses
|
||||
const fetchPromise = fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, clone);
|
||||
});
|
||||
caches.open(CACHE_VERSION).then((c) => c.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
}).catch(() => {
|
||||
// Return cached if fetch fails
|
||||
return cached;
|
||||
});
|
||||
})
|
||||
.catch(() => cached);
|
||||
|
||||
return cached || fetchPromise;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
const data = event.data.json();
|
||||
const options = {
|
||||
let data = { title: 'Dumpster', body: '', url: '/', tag: 'dumpster-notification' };
|
||||
try {
|
||||
if (event.data) {
|
||||
data = { ...data, ...event.data.json() };
|
||||
}
|
||||
} catch (e) {
|
||||
data.body = event.data ? event.data.text() : '';
|
||||
}
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge.png',
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
url: data.url || '/',
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Dumpster', options)
|
||||
badge: '/icons/icon-192.png',
|
||||
vibrate: [200, 100, 200],
|
||||
tag: data.tag,
|
||||
renotify: true,
|
||||
data: { url: data.url },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click: focus or open window
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
// Focus existing window if available
|
||||
for (const client of clientList) {
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => {
|
||||
for (const client of list) {
|
||||
if (client.url.includes(event.notification.data.url) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Otherwise open new window
|
||||
return clients.openWindow(event.notification.data.url);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||