Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92be2a30d1 | |||
| f4f6e8560b | |||
| 1900dd9cb1 | |||
| 1226bd28aa | |||
| 7cdee73542 | |||
| e8ba8ffdba | |||
| 52298d1d46 | |||
| 7fc5d66b8c | |||
| 7a6b4f961a | |||
| 71ee9c59c4 | |||
| 3e343a9c9b | |||
| 9371616508 | |||
| 5951c91102 | |||
| ff431d7f81 | |||
| aa5fdbe8c4 | |||
| 4da08d91bc | |||
| 11b1089126 | |||
| fd7fa4a147 | |||
| 7bf1eaf845 | |||
| 13bd4478f6 | |||
| 53530ce6dd | |||
| f215f000b8 | |||
| 191fe2a89f | |||
| eb5f38de1c | |||
| 51eb2ed310 | |||
| bd73d79b56 | |||
| 4b32655e67 | |||
| c839e67c47 | |||
| 5bdb758d23 | |||
| 56af584ede | |||
| 87d7345155 | |||
| a7646481a4 | |||
| 5d37fb899d | |||
| af913b9923 | |||
| 9a6f15662b | |||
| 7d1bd02e31 | |||
| 8542243745 | |||
| 90bddc65d4 | |||
| 67a5a54244 | |||
| 33c9dc4f15 | |||
| e6dfe43926 | |||
| 62e8354d03 | |||
| 1139e90fc3 | |||
| 60edc0b5b4 | |||
| 836bcd8b8d | |||
| 8fd649356c | |||
| 4a02d054cc | |||
| dbd4de8e61 | |||
| a0bb229173 | |||
| c86c602294 | |||
| aa39b6a5be | |||
| 8cf1e91b2b | |||
| f068e15398 | |||
| 559d17d1f2 | |||
| ea72050202 | |||
| 88194b17c4 | |||
| 2b45b11ea8 | |||
| b3b5ff495d | |||
| 9512dedec1 | |||
| 8bbe2a0ff0 | |||
| c2fb3165f4 | |||
| 0c26e2402b | |||
| 88e7faaafb | |||
| 1d37284c25 | |||
| b1b41ca5e1 | |||
| e10cb22811 | |||
| 2631e6ce4a | |||
| 239640975f | |||
| ed408c4044 | |||
| 9aeedb1a53 | |||
| 59846fc1e4 | |||
| 944ae4074e | |||
| 549bf842d0 | |||
| 83c05731ed | |||
| 5d8f420882 | |||
| 074742ddb4 | |||
| f9b1e16e3a | |||
| f9edd8c069 | |||
| 7e1c0b822b | |||
| f61b33ff81 | |||
| ed50815902 | |||
| b4e2f97dba | |||
| 53f8c6b2ef | |||
| d1022d7c2a | |||
| 15f9f6ae07 | |||
| c803991cda | |||
| a502cd8fd4 | |||
| a593f04d7f | |||
| 6b7dc2addd | |||
| 8336ca5d87 | |||
| 85f9c21f4c | |||
| 92af2e21b1 | |||
| 1e22daead3 | |||
| 9e70884a16 | |||
| b6d4614881 | |||
| 0b645fe765 | |||
| f6f63ecd2b | |||
| f30ddb1323 | |||
| 5ca5e018ca | |||
| 3002626ac4 | |||
| 4ed2e5bfd0 | |||
| aa80e354c9 | |||
| cf2f1c96a3 | |||
| 40f8d193ff | |||
| d8b4defaff | |||
| 722eab8e94 | |||
| 19ac68d058 | |||
| 3a04eff42b | |||
| 59f6a40882 | |||
| 5a7d4dc57e | |||
| 89f8381e2d | |||
| eb5b7d55a4 | |||
| 7d67d6f360 | |||
| 08dfc4e400 | |||
| cbfbcb2627 | |||
| ca7a9e30f1 | |||
| 10a702f732 | |||
| af961d805e | |||
| 7f2a58599d | |||
| 67ce6bbfd9 | |||
| c0686d4ca3 | |||
| 74a1db8392 | |||
| 5cc9d76394 | |||
| 3aa1ca3ead | |||
| 3ed66f3d43 | |||
| a5c3b25cc0 | |||
| 17c09edc4f | |||
| d0da35e816 | |||
| 31cb295a24 | |||
| ecb26cb731 | |||
| cb8aeddde6 | |||
| 33dc63b94c | |||
| 48a99d58ee | |||
| c80a36bacb | |||
| 65de4efb0d | |||
| b8ae43e250 | |||
| 82ddf914e4 | |||
| f4437c5e1d |
@@ -20,6 +20,6 @@ LIVEKIT_API_SECRET=<gener...n
|
|||||||
# Web Push
|
# Web Push
|
||||||
VAPID_PUBLIC_KEY=<generated>
|
VAPID_PUBLIC_KEY=<generated>
|
||||||
VAPID_PRIVATE_KEY=<generated>
|
VAPID_PRIVATE_KEY=<generated>
|
||||||
VAPID_SUBJECT=mailto:admin@your.domain
|
VAPID_SUBJECT=admin@your.domain
|
||||||
# Integrations
|
# Integrations
|
||||||
GIPHY_API_KEY=your_giphy_api_key_here
|
GIPHY_API_KEY=your_giphy_api_key_here
|
||||||
|
|||||||
@@ -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,5 @@ minio_data/
|
|||||||
/server
|
/server
|
||||||
/migrate
|
/migrate
|
||||||
/dumpster-server
|
/dumpster-server
|
||||||
|
keygen
|
||||||
|
steamfree
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## vs Discord, Guilded (historical), TeamSpeak 6, Fluxer
|
## 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 |
|
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||||
|| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|
| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|
||||||
|| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Threads | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Threads | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
|| Forum channels | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Forum channels | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
|| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
||||||
|| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
||||||
|| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Message history (pagination) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Message history (pagination) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|| Read receipts | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Read receipts | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
|
| @everyone / @channel | ✅ (perm gated) | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Badges | ❌ | ✅ | ✅ | ✅ | ✅ |
|
| Badges | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Usernames + discriminators | ✅ | ⚠️ handles | ❌ | ✅ UID | ✅ #0000 |
|
| Usernames + discriminators | ✅ | ⚠️ handles | ❌ | ✅ UID | ✅ #0000 |
|
||||||
| Friend requests | ❌ | ✅ | ✅ | ❌ | ✅ |
|
| Friend requests | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
|
| Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||||
| Activity / game status | ❌ | ✅ | ✅ | ❌ | ❌ |
|
| Activity / game status | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -80,11 +81,12 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Roles | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Roles | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ |
|
| Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ |
|
| Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ |
|
||||||
|| Per-channel permission overrides | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Per-channel permission overrides | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ |
|
| Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
| Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
||||||
| Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Client-side permission gates | ✅ (user roles + @everyone) | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
### dumpsterChat Permissions (current)
|
### dumpsterChat Permissions (current)
|
||||||
|
|
||||||
@@ -123,7 +125,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Server invites | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Server invites | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Vanity URLs | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
| Vanity URLs | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
||||||
| Webhooks | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Webhooks | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Bots / API | ⚠️ slash cmds | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
|
| Bots / API | ⚠️ store + runner | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
|
||||||
| Server templates | ❌ | ✅ | ❌ | ❌ | ❌ |
|
| Server templates | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| Server discovery | ❌ | ✅ | ✅ | ✅ | 🔄 |
|
| Server discovery | ❌ | ✅ | ✅ | ✅ | 🔄 |
|
||||||
| Server analytics | ❌ | ✅ | ✅ | ❌ | ❌ |
|
| Server analytics | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||||
@@ -139,10 +141,10 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Desktop notifications | ⚠️ possible via SW | ✅ | ✅ | ✅ | ✅ |
|
| Desktop notifications | ⚠️ possible via SW | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Web push notifications | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Web push notifications | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| @mention push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| @mention push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Channel-wide push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Channel-wide push (@everyone/@channel) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Email notifications | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
| Email notifications | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Mobile apps | ❌ | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
|
| Mobile apps | ❌ (PWA is target) | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
|
||||||
| Per-channel notification settings | ❌ | ✅ | ✅ | ✅ | ✅ |
|
| Per-channel notification settings | ⚠️ partial | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Do Not Disturb schedule | ❌ | ✅ | ❌ | ❌ | ❌ |
|
| Do Not Disturb schedule | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -156,6 +158,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Slash commands | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Slash commands | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Command options / JSON schema | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Command options / JSON schema | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Bot mentions | ✅ | ✅ | ❌ | ❌ | 🔄 |
|
| Bot mentions | ✅ | ✅ | ❌ | ❌ | 🔄 |
|
||||||
|
| Built-in bot runner (anonConfess, leaderboard, steamfree) | ✅ | ❌ | ⚠️ | ❌ | ❌ |
|
||||||
| Third-party integrations (Twitch, YouTube, GitHub) | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
| Third-party integrations (Twitch, YouTube, GitHub) | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Webhook-driven bots | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Webhook-driven bots | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
|
||||||
@@ -168,40 +171,11 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
- Terminal/Gruvbox aesthetic
|
- Terminal/Gruvbox aesthetic
|
||||||
- WebAuthn / passkey auth
|
- WebAuthn / passkey auth
|
||||||
- LiveKit voice integration
|
- LiveKit voice integration
|
||||||
- Built-in webhook execution for simple integrations
|
- Built-in bot store + managed runner
|
||||||
|
- PWA-first mobile (no native app planned)
|
||||||
|
|
||||||
### Discord
|
### Discord / Guilded / TeamSpeak / Fluxer
|
||||||
- Massive network effect (200M+ MAU)
|
See historical notes in git history if needed. Not the product roadmap.
|
||||||
- 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -210,38 +184,37 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||||
| Self-hostable | ✅ | ❌ | ❌ | ✅ | ✅ |
|
| Self-hostable | ✅ | ❌ | ❌ | ✅ | ✅ |
|
||||||
| Open source | ❌ | ❌ | ❌ | ❌ | ✅ AGPL-3 |
|
| Open source | ⚠️ private self-host | ❌ | ❌ | ❌ | ✅ AGPL-3 |
|
||||||
| PWA support | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| PWA support | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| REST API | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| REST API | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| WebSocket gateway | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| WebSocket gateway | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Swagger docs | ✅ localhost | ✅ | ✅ | ❌ | ✅ |
|
| Swagger docs | ✅ /docs | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Docker Compose | ✅ | ❌ | ❌ | ❌ | ✅ |
|
| 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
|
### Actual next polish (product)
|
||||||
2. **Mobile app** — major adoption blocker
|
1. Mobile PWA pain (input, notifs, safe areas)
|
||||||
3. **Voice push-to-talk** — important for voice-heavy communities
|
2. Role color UI / hierarchy polish
|
||||||
4. **Email notifications** — needed for async engagement
|
3. Voice PTT / screen share only if voice is used
|
||||||
5. **Read receipts / unread state** — channel-level read tracking
|
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
|
### Explicitly not critical
|
||||||
2. **Custom emoji / reactions beyond unicode** — core Discord behavior
|
- Friend requests (DMs already exist among members)
|
||||||
3. **Server groups (sub-servers)** — channel organization
|
- Native mobile apps
|
||||||
4. **Do Not Disturb schedule** — notification control
|
- Discord bot ecosystem compatibility
|
||||||
5. **Third-party integrations** — Twitch, YouTube, GitHub
|
- Server discovery / monetization / federation
|
||||||
6. **Stage channels** — presentation-style voice
|
- AutoMod / Flow Bots
|
||||||
|
|
||||||
### Nice-to-have differentiators
|
### Stale claims removed
|
||||||
|
- ~~"Direct Messages missing"~~ — DMs exist
|
||||||
1. **No-code Flow Bots** (Guilded-style automations)
|
- ~~"Mobile app is the only path"~~ — PWA is the target client
|
||||||
2. **Server discovery / directory**
|
|
||||||
3. **Activities / embedded games**
|
|
||||||
4. **Federation** (Fluxer-style)
|
|
||||||
5. **Server analytics**
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
SHELL := /bin/bash
|
SHELL := /bin/bash
|
||||||
.PHONY: build build-web build-server build-tui run run-tui dev docker-up docker-down clean
|
.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 everything
|
||||||
build: build-web build-server
|
build: build-web build-server
|
||||||
|
|
||||||
@@ -12,13 +14,17 @@ build-server:
|
|||||||
build-tui:
|
build-tui:
|
||||||
CGO_ENABLED=0 go build -o dumpster-tui ./cmd/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
|
||||||
|
|
||||||
# Generate Swagger docs
|
# Generate Swagger docs
|
||||||
docs:
|
docs:
|
||||||
~/go/bin/swag init -g cmd/server/main.go -o docs
|
~/go/bin/swag init -g cmd/server/main.go -o docs
|
||||||
|
|
||||||
# Build the web frontend
|
# Build the web frontend
|
||||||
build-web:
|
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 the server locally (dev mode)
|
||||||
run: build-server
|
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] Blocks (user-level blocking)
|
||||||
- [x] Dark/light mode toggle
|
- [x] Dark/light mode toggle
|
||||||
- [x] Mobile-responsive layout (bottom nav, drawer)
|
- [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] Slash commands (registration, autocomplete)
|
||||||
- [x] Incoming webhooks (create, execute)
|
- [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] TUI client (Bubbletea, vim-style, voice support)
|
||||||
- [x] Roles & permissions system
|
- [x] Roles & permissions system
|
||||||
- [x] Push notification backend (VAPID)
|
- [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] WebAuthn backend (full begin/finish flow)
|
||||||
- [x] REST API documentation (Swagger UI at /docs)
|
- [x] REST API documentation (Swagger UI at /docs)
|
||||||
- [x] Push notification mention parsing and dispatch
|
- [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
|
## Project Structure
|
||||||
|
|
||||||
@@ -94,8 +100,9 @@ dumpsterChat/
|
|||||||
│ ├── voice/ # LiveKit voice/video integration
|
│ ├── voice/ # LiveKit voice/video integration
|
||||||
│ ├── reaction/ # Message reactions
|
│ ├── reaction/ # Message reactions
|
||||||
│ ├── invite/ # Server invite links
|
│ ├── invite/ # Server invite links
|
||||||
│ ├── bot/ # Bot framework, auth, commands
|
│ ├── bot/ # Bot framework, auth, commands, runner
|
||||||
│ ├── webhook/ # Incoming webhooks
|
│ ├── webhook/ # Incoming webhooks
|
||||||
|
│ ├── dm/ # Direct messages (conversations)
|
||||||
│ ├── push/ # Push notification sender (VAPID)
|
│ ├── push/ # Push notification sender (VAPID)
|
||||||
│ ├── permissions/ # Permission bitflags and checker
|
│ ├── permissions/ # Permission bitflags and checker
|
||||||
│ ├── moderation/ # Mute, ban, kick, slowmode, audit log
|
│ ├── moderation/ # Mute, ban, kick, slowmode, audit log
|
||||||
@@ -108,7 +115,8 @@ dumpsterChat/
|
|||||||
│ └── block/ # User blocking
|
│ └── block/ # User blocking
|
||||||
├── examples/ # Example bots
|
├── examples/ # Example bots
|
||||||
│ ├── modbot/ # Moderation bot
|
│ ├── modbot/ # Moderation bot
|
||||||
│ └── welcome/ # Welcome message bot
|
│ ├── welcome/ # Welcome message bot
|
||||||
|
│ └── steamfree/ # Steam free games bot (standalone)
|
||||||
├── web/ # React frontend (PWA)
|
├── web/ # React frontend (PWA)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # Layout, ChatArea, LoginForm, UserSettings, GiphyPicker, VoiceChannel, VoicePanel, VoiceControls, TypingIndicator, ReactionBar, EmojiPicker, ReplyBar, MentionPopup, InviteModal, JoinServer, MobileNav, MobileDrawer, ThemeToggle, InstallPrompt, BotManager, CommandManager, SlashCommandPopup
|
│ │ ├── 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 |
|
| `LIVEKIT_*` | Voice/video server | No |
|
||||||
| `GIPHY_API_KEY` | Giphy API key for GIF search | No |
|
| `GIPHY_API_KEY` | Giphy API key for GIF search | No |
|
||||||
| `MINIO_*` | File upload storage | 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
|
## Wiki
|
||||||
|
|
||||||
Full documentation: [dumpsterChat wiki](ssh://git@git.dustin.coffee:2222/hobokenchicken/dumpsterChat.wiki.git)
|
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
|
## License
|
||||||
|
|
||||||
AGPLv3
|
AGPLv3
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDkTCCAxegAwIBAgISBQjShZO8+oxeU80iyTOUKHhdMAoGCCqGSM49BAMDMDMx
|
||||||
|
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZ
|
||||||
|
RTIwHhcNMjYwNzA2MDEzMDM1WhcNMjYxMDA0MDEzMDM0WjAdMRswGQYDVQQDExJ0
|
||||||
|
dXJuLmR1c3Rpbi5jb2ZmZWUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATB70bx
|
||||||
|
IRjXyxdwR/3k56AkiFRKwApxq/h7xNXK5/I5xZ+5+HCLMHjEmFOIUtUj87DpcPHs
|
||||||
|
rAfJZcH19gQIt7Xlo4ICHzCCAhswDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG
|
||||||
|
CCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFE8HllwR3SN9Ay0rvVLX
|
||||||
|
9mOsdJ0UMB8GA1UdIwQYMBaAFLlZ8o7PIvCG0zdI/3YUGLqC2FWHMDMGCCsGAQUF
|
||||||
|
BwEBBCcwJTAjBggrBgEFBQcwAoYXaHR0cDovL3llMi5pLmxlbmNyLm9yZy8wHQYD
|
||||||
|
VR0RBBYwFIISdHVybi5kdXN0aW4uY29mZmVlMBMGA1UdIAQMMAowCAYGZ4EMAQIB
|
||||||
|
MC4GA1UdHwQnMCUwI6AhoB+GHWh0dHA6Ly95ZTIuYy5sZW5jci5vcmcvOTQuY3Js
|
||||||
|
MIIBCwYKKwYBBAHWeQIEAgSB/ASB+QD3AHUAwjF+V0UZo0XufzjespBB68fCIVoi
|
||||||
|
v3/Vta12mtkOUs0AAAGfNUH9yAAABAMARjBEAiBUIFA17umU+cAdlBJlkjhivt7h
|
||||||
|
4f1HF7kdlQtt0hURaAIgRWAiqclKJy3QikfbGF7EFK3sNRyf/PZNBLtjJeieP1IA
|
||||||
|
fgAm42RuWGkhI7w0P0ckNZs3ks0kWojYFdOTM/2ZGKtHIwAAAZ81QfvIAAgAAAUA
|
||||||
|
JeWwugQDAEcwRQIhAJ9gzEj26/VOpYO0HkS2ZcItIhoBf7bDfzt9gpIK2RH2AiA2
|
||||||
|
66DvuRKPYthZ4AFBXbVtSX4rf4aX2IvgQIWTudJMlDAKBggqhkjOPQQDAwNoADBl
|
||||||
|
AjANu3rbmE/SqrfsMNGEv7rl+qvblv0btupKZ2qHg87079XD4gTh3Sqyl+Z4RnHq
|
||||||
|
JkkCMQDI3q5ZQ6GVF3geVTbNvLlJ+R35eB+EIcoX0Cbs20S5k9N/SiWS2BlQzlUM
|
||||||
|
QpPxaok=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIICjDCCAhGgAwIBAgIQTfOxXdbAeExQfNN7WObxFTAKBggqhkjOPQQDAzAuMQsw
|
||||||
|
CQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZRTAeFw0y
|
||||||
|
NTA5MDMwMDAwMDBaFw0yODA5MDIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYwFAYD
|
||||||
|
VQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZRTIwdjAQBgcqhkjOPQIBBgUr
|
||||||
|
gQQAIgNiAARxmrQzkdbEEL3MqXt3dJQttYc47axkdDTHud5TPqM2z5uSD5cmk0Wr
|
||||||
|
HlWXvnlvqBLqiB34kluxIbmMyAiq3/YD6e80/vV259K8XQIdjFXloYOa0mIU71f7
|
||||||
|
HQ09PvYDlw+jge4wgeswDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF
|
||||||
|
BwMBMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFLlZ8o7PIvCG0zdI/3YU
|
||||||
|
GLqC2FWHMB8GA1UdIwQYMBaAFKPIJlqOoUzQNWP8myPIOq5W809WMDIGCCsGAQUF
|
||||||
|
BwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3llLmkubGVuY3Iub3JnLzATBgNV
|
||||||
|
HSAEDDAKMAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veWUuYy5s
|
||||||
|
ZW5jci5vcmcvMAoGCCqGSM49BAMDA2kAMGYCMQDIcnw5dcZLN9ffynXnnkLD/itS
|
||||||
|
JEycJPb3sRkzeqBowup7vOsAwaqoCnNn/jh9wycCMQCJM6CPlaOC4pQYYbJtVPYb
|
||||||
|
DKrIb2EKk5NpOpE6/XttQYZV/3gilB9l+Cc/DOVwmyg=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIICpjCCAiugAwIBAgIRAIchZfw0tuX7qK3Vs3BftTowCgYIKoZIzj0EAwMwTzEL
|
||||||
|
MAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNo
|
||||||
|
IEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDIwHhcNMjYwNTEzMDAwMDAwWhcN
|
||||||
|
MzIwOTAyMjM1OTU5WjAuMQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4G
|
||||||
|
A1UEAxMHUm9vdCBZRTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDwS/6vhrcVqcbBo
|
||||||
|
+wgdI3fwn9x7DNJJOY/lTOti0vkwuRN87RhEhTH17E7XyFjWsPYhIPt/wzOqxTd2
|
||||||
|
b+4ZJNy9ID04YywF9U5zasDVyGSNErVNtz8uSGh5izW87j77GaOB6zCB6DAOBgNV
|
||||||
|
HQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH/BAUwAwEB
|
||||||
|
/zAdBgNVHQ4EFgQUo8gmWo6hTNA1Y/ybI8g6rlbzT1YwHwYDVR0jBBgwFoAUfEKW
|
||||||
|
rt5LSDv6kviejM9ti6lyN5UwMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAChhZo
|
||||||
|
dHRwOi8veDIuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcGA1Ud
|
||||||
|
HwQgMB4wHKAaoBiGFmh0dHA6Ly94Mi5jLmxlbmNyLm9yZy8wCgYIKoZIzj0EAwMD
|
||||||
|
aQAwZgIxAMU19WCtmxVND8UHBZRoma49Z7jPs64Dma0eTu1OChVbB/2J7GV3nvYK
|
||||||
|
Ax54uk1G9QIxAO0miLVJu8PLNiXXXkiE/gsK3CTRTF/aeo4bMX42Zw40csRU6AC2
|
||||||
|
6hSW1/IWaas6dg==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIEcDCCAligAwIBAgIQbI8dxyfHEX97r4U6yYD5zTANBgkqhkiG9w0BAQsFADBP
|
||||||
|
MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy
|
||||||
|
Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNjA1MTMwMDAwMDBa
|
||||||
|
Fw0zMjA5MDIyMzU5NTlaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5l
|
||||||
|
dCBTZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgy
|
||||||
|
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0H
|
||||||
|
ttwW+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7
|
||||||
|
AlF9ItgKbppbd9/w+kHsOdx1ymgHDB/qo4H1MIHyMA4GA1UdDwEB/wQEAwIBBjAd
|
||||||
|
BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUwAwEB/zAd
|
||||||
|
BgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwHwYDVR0jBBgwFoAUebRZ5nu2
|
||||||
|
5eQBc4AIiMgaWPbpm24wMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAChhZodHRw
|
||||||
|
Oi8veDEuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcGA1UdHwQg
|
||||||
|
MB4wHKAaoBiGFmh0dHA6Ly94MS5jLmxlbmNyLm9yZy8wDQYJKoZIhvcNAQELBQAD
|
||||||
|
ggIBAD2/e9frmMxNpCV03qUHegg+MV2wz9644YoXdqtH8RyWYcBO7xfjjGEXdU1e
|
||||||
|
/o0OkEFiynUCOSIk/vLLo7ttz6CPAeNlWfC0XNkoGeWgK6jjXvozBaGuGH5n0Ufo
|
||||||
|
shMeWTuURqNN5G00sSXDTBrpp2+mgvdZQjb8K11TYMA25QA+YHNfbIEL0BniAhKS
|
||||||
|
2gsnJjSzrdZLI+EZ7SEyqdR2rkjd1KutLDU+n3TFyxjniZVGur4YlhMP3mY/dV95
|
||||||
|
IruAkkjOZier6hGBdEgZXXvaCz9u9iVEadsIE75pAGL8oHV5vxdARDiotRpul1IN
|
||||||
|
/UZwzAbrfUFcw1HkAcYD/mlZfnQ2ieCF2MS7j3Vhv7JPDKp45fmykmzYNSrumRW0
|
||||||
|
upFFKDBOoF7hsOb7oLyHS+Uft6jOUfOrogj8YUx38hKb2K20r42OgsSdDdxdeYWc
|
||||||
|
MS3Sb6mwJeSZEYxJ2gaXnDSPaKhhrNkYwljyVQyr4Nq+MEJytXNTnHqaAcrNwZlV
|
||||||
|
pcJL1KBnMrMjP7eanvUwL3FYj3cF17jtboLt7gLoi4+2rWZFvn+w54jmd/FIuhhZ
|
||||||
|
cEaU/wvU6BUNMtcVquVGHp7itQeDth5j+XL3j4WJ2SABwzUl6OeYdgpIt/ITZa+p
|
||||||
|
TT0mQ/r5XyA4MEAiabn7XJjvCERlF2dcn2wqJw+CreTkkQ2R
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggnJOfQM8flEJFULr
|
||||||
|
8vvHwWRlXlJ1a0sI5Jw/Y6KRQeGhRANCAATB70bxIRjXyxdwR/3k56AkiFRKwApx
|
||||||
|
q/h7xNXK5/I5xZ+5+HCLMHjEmFOIUtUj87DpcPHsrAfJZcH19gQIt7Xl
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -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,
|
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
edited_at TIMESTAMPTZ,
|
edited_at TIMESTAMPTZ,
|
||||||
|
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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_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_sessions_token ON sessions(token);
|
||||||
CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id);
|
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 = `
|
const downSQL = `
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ import (
|
|||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/dm"
|
"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/gateway"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
|
||||||
@@ -34,6 +37,7 @@ import (
|
|||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
httpSwagger "github.com/swaggo/http-swagger"
|
httpSwagger "github.com/swaggo/http-swagger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,7 +71,7 @@ func main() {
|
|||||||
sessionStore := auth.NewSessionStore(database.DB, cfg)
|
sessionStore := auth.NewSessionStore(database.DB, cfg)
|
||||||
|
|
||||||
// WebSocket origin allowlist
|
// 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" {
|
if cfg.Host == "localhost" {
|
||||||
wsOrigins = append(wsOrigins, "http://localhost:"+cfg.Port)
|
wsOrigins = append(wsOrigins, "http://localhost:"+cfg.Port)
|
||||||
}
|
}
|
||||||
@@ -77,6 +81,13 @@ func main() {
|
|||||||
hub := gateway.NewHub(database.DB, logger)
|
hub := gateway.NewHub(database.DB, logger)
|
||||||
go hub.Run()
|
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)
|
// Giphy client (nil if no API key)
|
||||||
giphyClient := giphy.NewClient(cfg.Giphy.APIKey)
|
giphyClient := giphy.NewClient(cfg.Giphy.APIKey)
|
||||||
|
|
||||||
@@ -98,14 +109,27 @@ func main() {
|
|||||||
logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)")
|
logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Email mailer
|
||||||
|
mailer := email.NewMailer(cfg.SMTP)
|
||||||
|
|
||||||
// Auth handler
|
// Auth handler
|
||||||
authHandler := auth.NewHandler(database.DB, cfg, hub)
|
authHandler := auth.NewHandler(database.DB, cfg, hub, mailer)
|
||||||
|
|
||||||
// Permissions checker
|
// Permissions checker
|
||||||
permissionsChecker := permissions.NewChecker(database.DB)
|
permissionsChecker := permissions.NewChecker(database.DB)
|
||||||
memberHandler := server.NewMemberHandler(database.DB)
|
memberHandler := server.NewMemberHandler(database.DB)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
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"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
MaxAge: 300,
|
||||||
|
}))
|
||||||
|
|
||||||
r.Use(chimw.Logger)
|
r.Use(chimw.Logger)
|
||||||
r.Use(chimw.Recoverer)
|
r.Use(chimw.Recoverer)
|
||||||
r.Use(chimw.RequestID)
|
r.Use(chimw.RequestID)
|
||||||
@@ -122,6 +146,11 @@ func main() {
|
|||||||
gateway.ServeWS(database.DB, hub, logger, w, r, cfg.Session.CookieName)
|
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
|
// API routes
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
// Auth (public: register, login, logout) with strict rate limiting
|
// Auth (public: register, login, logout) with strict rate limiting
|
||||||
@@ -206,7 +235,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Direct messages
|
// Direct messages
|
||||||
dmHandler := dm.NewHandler(database.DB, hub, logger)
|
dmHandler := dm.NewHandler(database.DB, hub, pushHandler, logger)
|
||||||
r.Route("/conversations", func(r chi.Router) {
|
r.Route("/conversations", func(r chi.Router) {
|
||||||
dmHandler.RegisterRoutes(r)
|
dmHandler.RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
@@ -217,7 +246,21 @@ func main() {
|
|||||||
|
|
||||||
// Messages (under channels)
|
// Messages (under channels)
|
||||||
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
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
|
// Per-channel notification settings
|
||||||
@@ -225,6 +268,29 @@ func main() {
|
|||||||
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
|
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
|
// Read receipts
|
||||||
rsHandler := readstate.NewHandler(database.DB, logger)
|
rsHandler := readstate.NewHandler(database.DB, logger)
|
||||||
r.Put("/channels/{channelID}/read", rsHandler.MarkRead)
|
r.Put("/channels/{channelID}/read", rsHandler.MarkRead)
|
||||||
@@ -255,6 +321,34 @@ func main() {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(gifs)
|
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
|
// File upload
|
||||||
@@ -274,13 +368,9 @@ func main() {
|
|||||||
reaction.NewHandler(database.DB, hub).RegisterRoutes(r)
|
reaction.NewHandler(database.DB, hub).RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bots
|
// Bots + slash commands
|
||||||
r.Route("/bots", func(r chi.Router) {
|
r.Route("/bots", func(r chi.Router) {
|
||||||
bot.NewHandler(database.DB).RegisterRoutes(r)
|
bot.NewHandler(database.DB, botRunner).RegisterRoutes(r)
|
||||||
})
|
|
||||||
|
|
||||||
// Bot slash commands
|
|
||||||
r.Route("/bots/{botID}/commands", func(r chi.Router) {
|
|
||||||
bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r)
|
bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -305,6 +395,12 @@ func main() {
|
|||||||
// File serving (public, for viewing uploaded files)
|
// File serving (public, for viewing uploaded files)
|
||||||
if uploadHandler != nil {
|
if uploadHandler != nil {
|
||||||
r.Get("/files/*", uploadHandler.Serve)
|
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)
|
// Public webhook execution (no auth required)
|
||||||
@@ -333,7 +429,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Static file serving for production (SPA)
|
// Static file serving for production (SPA)
|
||||||
staticDir := "/srv/web"
|
staticDir := "web/dist"
|
||||||
if _, err := os.Stat(staticDir); err == nil {
|
if _, err := os.Stat(staticDir); err == nil {
|
||||||
fileServer := http.FileServer(http.Dir(staticDir))
|
fileServer := http.FileServer(http.Dir(staticDir))
|
||||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
|
#HttpOnly_localhost FALSE / TRUE 1785435515 dumpster_session 720a3aad74029e6c8e0c4b93f8db0b48381afc5355e1c1345143a0f5e3d5ae34
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
auto_https off
|
auto_https off
|
||||||
|
https_port 8443
|
||||||
}
|
}
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
FROM golang:1.24-alpine AS go-builder
|
FROM golang:1.26.4-alpine AS go-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY cmd/ cmd/
|
COPY cmd/ cmd/
|
||||||
COPY internal/ internal/
|
COPY internal/ internal/
|
||||||
|
COPY docs/ docs/
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/dumpster-server ./cmd/server
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/dumpster-server ./cmd/server
|
||||||
|
|
||||||
FROM node:22-alpine AS web-builder
|
FROM node:22-alpine AS web-builder
|
||||||
|
|||||||
@@ -94,25 +94,29 @@ services:
|
|||||||
image: coturn/coturn:latest
|
image: coturn/coturn:latest
|
||||||
container_name: dumpster-coturn
|
container_name: dumpster-coturn
|
||||||
command:
|
command:
|
||||||
- "-n"
|
|
||||||
- "--log-file=stdout"
|
- "--log-file=stdout"
|
||||||
- "--min-port=10000"
|
- "--min-port=50101"
|
||||||
- "--max-port=20000"
|
- "--max-port=50200"
|
||||||
- "--realm=${DUMPSTER_HOST:-localhost}"
|
- "--realm=${DUMPSTER_HOST:-localhost}"
|
||||||
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
|
||||||
|
- "--cert=/etc/coturn/certs/turn.crt"
|
||||||
|
- "--pkey=/etc/coturn/certs/turn.key"
|
||||||
|
- "--tls-listening-port=5349"
|
||||||
ports:
|
ports:
|
||||||
- "3478:3478/tcp"
|
- "3478:3478/tcp"
|
||||||
- "3478:3478/udp"
|
- "3478:3478/udp"
|
||||||
- "5349:5349/tcp"
|
- "5349:5349/tcp"
|
||||||
- "5349:5349/udp"
|
- "5349:5349/udp"
|
||||||
- "10000-20000:10000-20000/udp"
|
- "50101-50200:50101-50200/udp"
|
||||||
|
volumes:
|
||||||
|
- ./certs:/etc/coturn/certs:ro
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2
|
image: caddy:2
|
||||||
container_name: dumpster-caddy
|
container_name: dumpster-caddy
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "8443:8443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
- web_assets:/srv/web:ro
|
- web_assets:/srv/web:ro
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
port: 7880
|
port: 7880
|
||||||
bind: 0.0.0.0
|
|
||||||
rtc:
|
rtc:
|
||||||
tcp_port: 7881
|
tcp_port: 7881
|
||||||
udp_port: 7882
|
udp_port: 7882
|
||||||
use_external_ip: true
|
use_external_ip: true
|
||||||
relay_range_start: 50000
|
port_range_start: 50000
|
||||||
relay_range_end: 50100
|
port_range_end: 50100
|
||||||
|
turn_servers:
|
||||||
|
- host: turn.dustin.coffee
|
||||||
|
port: 443
|
||||||
|
protocol: tls
|
||||||
|
username: V8S6EWOU1Lqp
|
||||||
|
credential: jQRJztgWbqDn9vjMUhHZg1ooxuhiBjhk
|
||||||
turn:
|
turn:
|
||||||
enabled: true
|
enabled: false
|
||||||
domain: livekit.localhost
|
|
||||||
tls_port: 5349
|
|
||||||
udp_port: 3478
|
|
||||||
external_tls: false
|
|
||||||
keys:
|
keys:
|
||||||
devkey: secret
|
V8S6EWOU1Lqp: jQRJztgWbqDn9vjMUhHZg1ooxuhiBjhk
|
||||||
|
|||||||
@@ -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/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/gammazero/deque v1.2.1 // 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/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 // 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/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 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: secure,
|
Secure: secure,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteNoneMode,
|
||||||
MaxAge: int(duration.Seconds()),
|
MaxAge: int(duration.Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
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()
|
||||||
|
_, 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, token)
|
||||||
|
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
|
||||||
|
`, 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()
|
||||||
|
_, 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, token)
|
||||||
|
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
|
||||||
|
`, 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"
|
"strings"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"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/gateway"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -21,14 +22,16 @@ type Handler struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
sessions *SessionStore
|
sessions *SessionStore
|
||||||
hub *gateway.Hub
|
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{
|
return &Handler{
|
||||||
db: db,
|
db: db,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
sessions: NewSessionStore(db, cfg),
|
sessions: NewSessionStore(db, cfg),
|
||||||
hub: hub,
|
hub: hub,
|
||||||
|
mailer: mailer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +39,14 @@ func (h *Handler) RegisterPublicRoutes(r chi.Router) {
|
|||||||
r.Post("/register", h.Register)
|
r.Post("/register", h.Register)
|
||||||
r.Post("/login/password", h.Login)
|
r.Post("/login/password", h.Login)
|
||||||
r.Post("/logout", h.Logout)
|
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) {
|
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.Get("/auth/me", h.Me)
|
||||||
r.Patch("/auth/me", h.UpdateProfile)
|
r.Patch("/auth/me", h.UpdateProfile)
|
||||||
r.Put("/auth/me/password", h.ChangePassword)
|
r.Put("/auth/me/password", h.ChangePassword)
|
||||||
@@ -210,16 +218,16 @@ type loginRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type updateProfileRequest struct {
|
type updateProfileRequest struct {
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL *string `json:"avatar_url"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Bio string `json:"bio"`
|
Bio *string `json:"bio"`
|
||||||
AccentColor string `json:"accent_color"`
|
AccentColor *string `json:"accent_color"`
|
||||||
StatusText string `json:"status_text"`
|
StatusText *string `json:"status_text"`
|
||||||
Status string `json:"status"`
|
Status *string `json:"status"`
|
||||||
BannerURL string `json:"banner_url"`
|
BannerURL *string `json:"banner_url"`
|
||||||
Tagline string `json:"tagline"`
|
Tagline *string `json:"tagline"`
|
||||||
Pronouns string `json:"pronouns"`
|
Pronouns *string `json:"pronouns"`
|
||||||
SocialLinks []struct {
|
SocialLinks *[]struct {
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
} `json:"social_links"`
|
} `json:"social_links"`
|
||||||
@@ -410,8 +418,16 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Failure 401 {object} map[string]string
|
// @Failure 401 {object} map[string]string
|
||||||
// @Router /auth/me [get]
|
// @Router /auth/me [get]
|
||||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
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())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
|
fmt.Printf("Me Request: NO USER ID IN CONTEXT\n")
|
||||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -450,52 +466,113 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
http.Error(w, `{"error":"bio too long"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.StatusText) > 128 {
|
if len(newStatusText) > 128 {
|
||||||
http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.DisplayName) > 32 {
|
if len(newDisplayName) > 32 {
|
||||||
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Tagline) > 128 {
|
if len(newTagline) > 128 {
|
||||||
http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Pronouns) > 40 {
|
if len(newPronouns) > 40 {
|
||||||
http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest)
|
||||||
return
|
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)
|
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Status != "" && !isValidStatus(req.Status) {
|
if newStatus != "" && !isValidStatus(newStatus) {
|
||||||
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
socialLinksJSON, _ := json.Marshal(req.SocialLinks)
|
var socialLinksJSON []byte
|
||||||
_, err := h.db.ExecContext(r.Context(), `
|
if req.SocialLinks != nil {
|
||||||
|
socialLinksJSON, _ = json.Marshal(*req.SocialLinks)
|
||||||
|
} else {
|
||||||
|
socialLinksJSON, _ = json.Marshal(existing.SocialLinks)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status),
|
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = $6,
|
||||||
banner_url = NULLIF($7, ''), tagline = NULLIF($8, ''), pronouns = NULLIF($9, ''), social_links = $10, avatar = COALESCE(NULLIF($11, ''), avatar)
|
banner_url = $7, tagline = $8, pronouns = $9, social_links = $10, avatar = $11
|
||||||
WHERE id = $1
|
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 {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Status != "" && h.hub != nil {
|
if req.Status != nil && h.hub != nil {
|
||||||
user, err := h.getProfile(r.Context(), userID)
|
user, err := h.getProfile(r.Context(), userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
h.hub.SetUserStatus(userID, req.Status)
|
h.hub.SetUserStatus(userID, *req.Status)
|
||||||
h.hub.BroadcastPresence(userID, user.Username, req.Status)
|
h.hub.BroadcastPresence(userID, user.Username, *req.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,8 @@ func (h *WebAuthnHandler) LoginBegin(w http.ResponseWriter, r *http.Request) {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
MaxAge: 300, // 5 minutes
|
MaxAge: 300, // 5 minutes
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteNoneMode,
|
||||||
|
Secure: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -341,7 +342,8 @@ func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteNoneMode,
|
||||||
|
Secure: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -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.
|
// Handler handles bot CRUD and server-assignment routes.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
runner *Runner
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new bot Handler.
|
// NewHandler creates a new bot Handler.
|
||||||
func NewHandler(db *sql.DB) *Handler {
|
func NewHandler(db *sql.DB, runner *Runner) *Handler {
|
||||||
return &Handler{db: db}
|
return &Handler{db: db, runner: runner}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRoutes registers authenticated bot routes under the given router.
|
// RegisterRoutes registers authenticated bot routes under the given router.
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
|
r.Get("/store", h.Store)
|
||||||
|
r.Get("/types", h.ListTypes)
|
||||||
r.Post("/", h.Create)
|
r.Post("/", h.Create)
|
||||||
r.Get("/", h.List)
|
r.Get("/", h.List)
|
||||||
r.Get("/{botID}", h.Get)
|
r.Get("/{botID}", h.Get)
|
||||||
@@ -39,6 +42,8 @@ type botResponse struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
BotType string `json:"bot_type"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
OwnerID string `json:"owner_id"`
|
OwnerID string `json:"owner_id"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -52,12 +57,16 @@ type botWithToken struct {
|
|||||||
type createBotRequest struct {
|
type createBotRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
BotType string `json:"bot_type"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateBotRequest struct {
|
type updateBotRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
|
BotType *string `json:"bot_type"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type addToServerRequest struct {
|
type addToServerRequest struct {
|
||||||
@@ -109,15 +118,21 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
token := GenerateToken()
|
token := GenerateToken()
|
||||||
tokenHash := HashToken(token)
|
tokenHash := HashToken(token)
|
||||||
|
|
||||||
|
configJSON := req.Config
|
||||||
|
if configJSON == nil {
|
||||||
|
configJSON = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
var bot botWithToken
|
var bot botWithToken
|
||||||
var avatar sql.NullString
|
var avatar sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
|
var configOut sql.NullString
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
INSERT INTO bots (name, description, owner_id, token)
|
INSERT INTO bots (name, description, owner_id, token, bot_type, config)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||||
RETURNING id, name, avatar, description, owner_id, created_at::text
|
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
|
||||||
`, req.Name, req.Description, userID, tokenHash).Scan(
|
`, req.Name, req.Description, userID, tokenHash, req.BotType, string(configJSON)).Scan(
|
||||||
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.OwnerID, &createdAt,
|
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.BotType, &configOut, &bot.OwnerID, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "failed to create bot")
|
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 {
|
if avatar.Valid {
|
||||||
bot.Avatar = &avatar.String
|
bot.Avatar = &avatar.String
|
||||||
}
|
}
|
||||||
|
if configOut.Valid {
|
||||||
|
bot.Config = json.RawMessage(configOut.String)
|
||||||
|
}
|
||||||
bot.CreatedAt = createdAt.String
|
bot.CreatedAt = createdAt.String
|
||||||
bot.Token = token
|
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)
|
writeJSON(w, http.StatusCreated, bot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,15 +304,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
var b botResponse
|
var b botResponse
|
||||||
var avatar sql.NullString
|
var avatar sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
|
var configOut sql.NullString
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
UPDATE bots
|
UPDATE bots
|
||||||
SET name = COALESCE($1, name),
|
SET name = COALESCE($1, name),
|
||||||
description = COALESCE($2, description),
|
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
|
WHERE id = $4
|
||||||
RETURNING id, name, avatar, description, owner_id, created_at::text
|
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
|
||||||
`, req.Name, req.Description, req.Avatar, botID).Scan(
|
`, req.Name, req.Description, req.Avatar, botID, req.BotType, string(req.Config)).Scan(
|
||||||
&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt,
|
&b.ID, &b.Name, &avatar, &b.Description, &b.BotType, &configOut, &b.OwnerID, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "failed to update bot")
|
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 {
|
if avatar.Valid {
|
||||||
b.Avatar = &avatar.String
|
b.Avatar = &avatar.String
|
||||||
}
|
}
|
||||||
|
if configOut.Valid {
|
||||||
|
b.Config = json.RawMessage(configOut.String)
|
||||||
|
}
|
||||||
b.CreatedAt = createdAt.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)
|
writeJSON(w, http.StatusOK, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +385,11 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop built-in bot if running
|
||||||
|
if h.runner != nil {
|
||||||
|
h.runner.Stop(botID)
|
||||||
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,3 +592,64 @@ func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeJSON(w, http.StatusOK, b)
|
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"`
|
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.Get("/{channelID}/events", h.ListEvents)
|
||||||
r.Post("/{channelID}/events", h.CreateEvent)
|
r.Post("/{channelID}/events", h.CreateEvent)
|
||||||
r.Patch("/events/{eventID}", h.UpdateEvent)
|
r.Patch("/events/{eventID}", h.UpdateEvent)
|
||||||
|
|||||||
@@ -27,11 +27,8 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Get("/{channelID}", h.Get)
|
r.Get("/{channelID}", h.Get)
|
||||||
r.Patch("/{channelID}", h.Update)
|
r.Patch("/{channelID}", h.Update)
|
||||||
r.Delete("/{channelID}", h.Delete)
|
r.Delete("/{channelID}", h.Delete)
|
||||||
r.Post("/{channelID}/threads", h.CreateThread)
|
// ponytail: thread + forum routes registered at top level in main.go to match frontend paths
|
||||||
r.Get("/{channelID}/threads", h.ListThreads)
|
h.RegisterCalendarRoutes(r)
|
||||||
r.Patch("/threads/{threadID}", h.UpdateThread)
|
|
||||||
h.registerForumRoutes(r)
|
|
||||||
h.registerCalendarRoutes(r)
|
|
||||||
h.registerDocRoutes(r)
|
h.registerDocRoutes(r)
|
||||||
h.registerListRoutes(r)
|
h.registerListRoutes(r)
|
||||||
h.registerOverrideRoutes(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)
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
return
|
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)
|
channels = append(channels, ch)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -339,6 +345,14 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
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)
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var ch channelResponse
|
var ch channelResponse
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
@@ -357,7 +372,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
category = COALESCE($3, category),
|
category = COALESCE($3, category),
|
||||||
position = COALESCE($4, position),
|
position = COALESCE($4, position),
|
||||||
slowmode_seconds = COALESCE($5, slowmode_seconds),
|
slowmode_seconds = COALESCE($5, slowmode_seconds),
|
||||||
group_id = $7
|
group_id = COALESCE($7, group_id)
|
||||||
WHERE id = $6
|
WHERE id = $6
|
||||||
RETURNING id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at
|
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(
|
`, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID, req.GroupID).Scan(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -51,6 +52,14 @@ type Config struct {
|
|||||||
WebPush WebPushConfig
|
WebPush WebPushConfig
|
||||||
Session SessionConfig
|
Session SessionConfig
|
||||||
Giphy GiphyConfig
|
Giphy GiphyConfig
|
||||||
|
SMTP SMTPConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type SMTPConfig struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
@@ -134,9 +143,31 @@ func Load() *Config {
|
|||||||
Giphy: GiphyConfig{
|
Giphy: GiphyConfig{
|
||||||
APIKey: getEnv("GIPHY_API_KEY", ""),
|
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 {
|
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
|
return "postgres://" + c.Database.User + ":" + c.Database.Password + "@" + c.Database.Host + ":" + c.Database.Port + "/" + c.Database.Database + "?sslmode=" + c.Database.SSLMode
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,19 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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 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 (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -79,10 +90,13 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
CREATE TABLE IF NOT EXISTS members (
|
CREATE TABLE IF NOT EXISTS members (
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
server_id UUID NOT NULL REFERENCES servers(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(),
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
PRIMARY KEY (user_id, server_id)
|
PRIMARY KEY (user_id, server_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE members ADD COLUMN IF NOT EXISTS nickname VARCHAR(100);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
@@ -90,6 +104,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
|
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||||
edited_at TIMESTAMPTZ,
|
edited_at TIMESTAMPTZ,
|
||||||
|
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -193,6 +208,7 @@ ALTER TABLE webhooks DROP COLUMN IF EXISTS token;
|
|||||||
|
|
||||||
-- Ensure reply_to column exists on messages (added after initial table creation)
|
-- 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 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 (
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -261,6 +277,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_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 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
|
-- Moderation
|
||||||
CREATE TABLE IF NOT EXISTS bans (
|
CREATE TABLE IF NOT EXISTS bans (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -493,5 +518,61 @@ CREATE TABLE IF NOT EXISTS server_groups (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_server_groups_server ON server_groups(server_id);
|
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;
|
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"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,12 +22,13 @@ import (
|
|||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
hub *gateway.Hub
|
hub *gateway.Hub
|
||||||
|
pushHandler *push.Handler
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new DM handler.
|
// NewHandler creates a new DM handler.
|
||||||
func NewHandler(db *sql.DB, hub *gateway.Hub, logger *slog.Logger) *Handler {
|
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger) *Handler {
|
||||||
return &Handler{db: db, hub: hub, logger: logger}
|
return &Handler{db: db, hub: hub, pushHandler: pushHandler, logger: logger}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRoutes registers conversation routes.
|
// RegisterRoutes registers conversation routes.
|
||||||
@@ -35,6 +39,10 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Get("/", h.Get)
|
r.Get("/", h.Get)
|
||||||
r.Get("/messages", h.ListMessages)
|
r.Get("/messages", h.ListMessages)
|
||||||
r.Post("/messages", h.SendMessage)
|
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)
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.UserIDs) == 0 {
|
// ponytail: user_ids can be empty for self-DM (notes to self)
|
||||||
http.Error(w, `{"error":"at least one user_id is required"}`, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build unique member set including creator.
|
// Build unique member set including creator.
|
||||||
memberSet := map[string]struct{}{userID: {}}
|
memberSet := map[string]struct{}{userID: {}}
|
||||||
@@ -82,10 +87,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
memberSet[uid] = struct{}{}
|
memberSet[uid] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(memberSet) == 1 {
|
// ponytail: allow self-DM (notes to self) — len(memberSet)==1 is fine
|
||||||
http.Error(w, `{"error":"cannot create conversation with yourself"}`, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
memberIDs := make([]string, 0, len(memberSet))
|
memberIDs := make([]string, 0, len(memberSet))
|
||||||
for uid := range memberSet {
|
for uid := range memberSet {
|
||||||
@@ -94,7 +96,24 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
sort.Strings(memberIDs)
|
sort.Strings(memberIDs)
|
||||||
|
|
||||||
// For a 1:1 DM, check if it already exists.
|
// 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
|
var existingID string
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
SELECT c.id FROM conversations c
|
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) {
|
func (h *Handler) buildResponse(ctx context.Context, convID string) (conversationResponse, error) {
|
||||||
var resp conversationResponse
|
var resp conversationResponse
|
||||||
err := h.db.QueryRowContext(ctx, `
|
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)
|
`, convID).Scan(&resp.ID, &resp.Type, &resp.Name, &resp.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
memberRows, err := h.db.QueryContext(ctx, `
|
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
|
FROM conversation_members cm
|
||||||
JOIN users u ON u.id = cm.user_id
|
JOIN users u ON u.id = cm.user_id
|
||||||
WHERE cm.conversation_id = $1
|
WHERE cm.conversation_id = $1
|
||||||
@@ -264,6 +283,7 @@ type messageResponse struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
EditedAt *string `json:"edited_at"`
|
EditedAt *string `json:"edited_at"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
Reactions []emojiGroup `json:"reactions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListMessages lists messages in a conversation.
|
// ListMessages lists messages in a conversation.
|
||||||
@@ -296,7 +316,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
FROM conversation_messages m
|
FROM conversation_messages m
|
||||||
JOIN users u ON u.id = m.author_id
|
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)
|
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
|
LIMIT $3
|
||||||
`, convID, before, limit)
|
`, convID, before, limit)
|
||||||
} else {
|
} else {
|
||||||
@@ -305,7 +325,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
FROM conversation_messages m
|
FROM conversation_messages m
|
||||||
JOIN users u ON u.id = m.author_id
|
JOIN users u ON u.id = m.author_id
|
||||||
WHERE m.conversation_id = $1
|
WHERE m.conversation_id = $1
|
||||||
ORDER BY m.created_at ASC
|
ORDER BY m.created_at DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
`, convID, limit)
|
`, convID, limit)
|
||||||
}
|
}
|
||||||
@@ -334,6 +354,13 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
messages = append(messages, msg)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(messages)
|
json.NewEncoder(w).Encode(messages)
|
||||||
}
|
}
|
||||||
@@ -390,6 +417,8 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
msg.DisplayName = &displayName.String
|
msg.DisplayName = &displayName.String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
||||||
|
|
||||||
if h.hub != nil {
|
if h.hub != nil {
|
||||||
h.hub.BroadcastToConversation(convID, gateway.Event{
|
h.hub.BroadcastToConversation(convID, gateway.Event{
|
||||||
Type: gateway.EventMessageCreate,
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(msg)
|
json.NewEncoder(w).Encode(msg)
|
||||||
@@ -424,3 +473,185 @@ func (h *Handler) MemberIDs(ctx context.Context, convID string) ([]string, error
|
|||||||
}
|
}
|
||||||
return ids, nil
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -105,6 +107,10 @@ type Client struct {
|
|||||||
Hub *Hub
|
Hub *Hub
|
||||||
Conn *websocket.Conn
|
Conn *websocket.Conn
|
||||||
UserID string
|
UserID string
|
||||||
|
Username string
|
||||||
|
IsBot bool
|
||||||
|
BotID string
|
||||||
|
BotName string
|
||||||
send chan []byte
|
send chan []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,8 +146,32 @@ func (c *Client) readPump() {
|
|||||||
c.Hub.RecordActivity(c.UserID)
|
c.Hub.RecordActivity(c.UserID)
|
||||||
|
|
||||||
switch event.Type {
|
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)
|
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:
|
case EventVoiceWhisper:
|
||||||
// Forward voice whispers only to the target user, not broadcast
|
// Forward voice whispers only to the target user, not broadcast
|
||||||
var whisperData struct {
|
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:
|
default:
|
||||||
c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID)
|
c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID)
|
||||||
}
|
}
|
||||||
@@ -247,11 +289,11 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
|||||||
token = auth.Token
|
token = auth.Token
|
||||||
}
|
}
|
||||||
|
|
||||||
var userID string
|
var userID, username string
|
||||||
err = db.QueryRowContext(context.Background(),
|
err = db.QueryRowContext(context.Background(),
|
||||||
`SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()`,
|
`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()`,
|
||||||
token,
|
token,
|
||||||
).Scan(&userID)
|
).Scan(&userID, &username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("ws auth: invalid session", "error", err)
|
logger.Warn("ws auth: invalid session", "error", err)
|
||||||
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid session"}`))
|
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid session"}`))
|
||||||
@@ -266,6 +308,7 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
|||||||
Hub: hub,
|
Hub: hub,
|
||||||
Conn: conn,
|
Conn: conn,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
send: make(chan []byte, 256),
|
send: make(chan []byte, 256),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,3 +317,195 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
|||||||
go client.writePump()
|
go client.writePump()
|
||||||
go client.readPump()
|
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"
|
EventVoiceMute = "VOICE_MUTE"
|
||||||
EventVoiceDeafen = "VOICE_DEAFEN"
|
EventVoiceDeafen = "VOICE_DEAFEN"
|
||||||
EventVoiceWhisper = "VOICE_WHISPER"
|
EventVoiceWhisper = "VOICE_WHISPER"
|
||||||
|
|
||||||
|
// Bot action events (sent by bot clients)
|
||||||
|
BotSendMessage = "SEND_MESSAGE"
|
||||||
|
BotDeleteMessage = "DELETE_MESSAGE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event represents a WebSocket event sent to clients.
|
// Event represents a WebSocket event sent to clients.
|
||||||
|
|||||||
@@ -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
|
pushHandler *push.Handler
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
checker *permissions.Checker
|
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 {
|
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) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.Get("/", h.List)
|
r.Get("/", h.List)
|
||||||
r.Post("/", h.Create)
|
r.Post("/", h.Create)
|
||||||
@@ -47,6 +59,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Post("/bulk-delete", h.BulkDelete)
|
r.Post("/bulk-delete", h.BulkDelete)
|
||||||
r.Patch("/{messageID}", h.Update)
|
r.Patch("/{messageID}", h.Update)
|
||||||
r.Delete("/{messageID}", h.Delete)
|
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 {
|
type bulkDeleteRequest struct {
|
||||||
@@ -116,6 +131,7 @@ func (h *Handler) BulkDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
Type: gateway.EventMessageDelete,
|
Type: gateway.EventMessageDelete,
|
||||||
Data: map[string]string{
|
Data: map[string]string{
|
||||||
"id": id,
|
"id": id,
|
||||||
|
"message_id": id,
|
||||||
"channel_id": channelID,
|
"channel_id": channelID,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -135,17 +151,38 @@ type embedResponse struct {
|
|||||||
SiteName string `json:"site_name"`
|
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 {
|
type messageResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ChannelID string `json:"channel_id"`
|
ChannelID string `json:"channel_id"`
|
||||||
AuthorID string `json:"author_id"`
|
AuthorID string `json:"author_id"`
|
||||||
AuthorName string `json:"author_username"`
|
AuthorName string `json:"author_username"`
|
||||||
DisplayName *string `json:"author_display_name"`
|
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"`
|
Content string `json:"content"`
|
||||||
ReplyTo *string `json:"reply_to,omitempty"`
|
ReplyTo *string `json:"reply_to,omitempty"`
|
||||||
EditedAt *string `json:"edited_at"`
|
EditedAt *string `json:"edited_at"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
Embeds []embedResponse `json:"embeds"`
|
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.
|
// 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
|
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 msg messageResponse
|
||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt 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(), `
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
INSERT INTO messages (channel_id, author_id, content, reply_to)
|
INSERT INTO messages (channel_id, author_id, content, reply_to)
|
||||||
VALUES ($1, $2, $3, $4)
|
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(
|
`, 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 {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
|
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.EditedAt = &editedAt.String
|
||||||
}
|
}
|
||||||
msg.CreatedAt = createdAt.String
|
msg.CreatedAt = createdAt.String
|
||||||
|
msg.Reactions = make([]emojiGroup, 0)
|
||||||
|
|
||||||
// Broadcast MESSAGE_CREATE event via WebSocket (scoped to server)
|
// Broadcast MESSAGE_CREATE event via WebSocket (scoped to server)
|
||||||
h.hub.BroadcastToServer(serverID, gateway.Event{
|
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(), `
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
UPDATE messages SET content = $1, edited_at = NOW()
|
UPDATE messages SET content = $1, edited_at = NOW()
|
||||||
WHERE id = $2 AND author_id = $3
|
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(
|
`, 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 err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
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.
|
// Re-fetch embeds after edit.
|
||||||
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
|
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
|
||||||
|
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
||||||
|
|
||||||
// Look up serverID for scoped broadcast
|
// Look up serverID for scoped broadcast
|
||||||
serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID)
|
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,
|
Type: gateway.EventMessageDelete,
|
||||||
Data: map[string]string{
|
Data: map[string]string{
|
||||||
"id": messageID,
|
"id": messageID,
|
||||||
|
"message_id": messageID,
|
||||||
"channel_id": channelID,
|
"channel_id": channelID,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -482,20 +545,22 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
var err error
|
var err error
|
||||||
if before != "" {
|
if before != "" {
|
||||||
rows, err = h.db.QueryContext(r.Context(), `
|
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
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
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)
|
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
|
LIMIT $3
|
||||||
`, channelID, before, limit)
|
`, channelID, before, limit)
|
||||||
} else {
|
} else {
|
||||||
rows, err = h.db.QueryContext(r.Context(), `
|
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
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
JOIN users u ON m.author_id = u.id
|
||||||
|
LEFT JOIN bots b ON m.bot_id = b.id
|
||||||
WHERE m.channel_id = $1
|
WHERE m.channel_id = $1
|
||||||
ORDER BY m.created_at ASC
|
ORDER BY m.created_at DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
`, channelID, limit)
|
`, channelID, limit)
|
||||||
}
|
}
|
||||||
@@ -511,7 +576,9 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
var replyTo 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 {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -521,11 +588,25 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
if editedAt.Valid {
|
if editedAt.Valid {
|
||||||
msg.EditedAt = &editedAt.String
|
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
|
msg.CreatedAt = createdAt.String
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
messages = h.attachEmbeds(r.Context(), messages)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(messages)
|
json.NewEncoder(w).Encode(messages)
|
||||||
@@ -565,6 +646,189 @@ func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse)
|
|||||||
return messages
|
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.
|
// Search searches messages in a channel using PostgreSQL full-text search.
|
||||||
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
||||||
channelID := chi.URLParam(r, "channelID")
|
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(), `
|
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,
|
||||||
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
|
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank, m.bot_id, b.name
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
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)
|
WHERE m.channel_id = $1 AND m.search_vector @@ plainto_tsquery('english', $2)
|
||||||
ORDER BY rank DESC, m.created_at DESC
|
ORDER BY rank DESC, m.created_at DESC
|
||||||
LIMIT $3
|
LIMIT $3
|
||||||
@@ -609,7 +874,9 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
var replyTo sql.NullString
|
var replyTo sql.NullString
|
||||||
var rank float64
|
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 {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -619,11 +886,19 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
if editedAt.Valid {
|
if editedAt.Valid {
|
||||||
msg.EditedAt = &editedAt.String
|
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
|
msg.CreatedAt = createdAt.String
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
messages = h.attachEmbeds(r.Context(), messages)
|
messages = h.attachEmbeds(r.Context(), messages)
|
||||||
|
messages = h.attachReactions(r.Context(), messages)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(messages)
|
json.NewEncoder(w).Encode(messages)
|
||||||
@@ -668,3 +943,228 @@ func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, c
|
|||||||
|
|
||||||
return userID, serverID, true
|
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"
|
"log/slog"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||||
)
|
)
|
||||||
|
|
||||||
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
|
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
|
||||||
var everyoneMention = "@everyone"
|
|
||||||
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
|
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
|
||||||
|
var plainUsernameMention = regexp.MustCompile(`@([a-zA-Z0-9_.-]+)`)
|
||||||
|
|
||||||
// MentionHandler dispatches push notifications for @mentions.
|
// MentionHandler dispatches push notifications for @mentions.
|
||||||
type MentionHandler struct {
|
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.
|
// ParseAndNotify parses message content for mentions and sends push notifications.
|
||||||
func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) {
|
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)
|
mentionedUsers := make(map[string]bool)
|
||||||
for _, match := range userMatches {
|
|
||||||
|
// Discord-style ID mentions
|
||||||
|
for _, match := range mentionRegex.FindAllStringSubmatch(content, -1) {
|
||||||
if len(match) > 1 {
|
if len(match) > 1 {
|
||||||
mentionedUsers[match[1]] = true
|
mentionedUsers[match[1]] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for @everyone
|
|
||||||
isEveryone := strings.Contains(content, everyoneMention)
|
|
||||||
|
|
||||||
// Get channel info for notification
|
// Get channel info for notification
|
||||||
var serverID, channelName string
|
var serverID, channelName string
|
||||||
err := m.db.QueryRowContext(ctx,
|
err := m.db.QueryRowContext(ctx,
|
||||||
@@ -75,8 +96,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
"url": "/channels/" + channelID,
|
"url": "/channels/" + channelID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if isEveryone {
|
// @everyone / @channel — fan out to server members (permission gated at create).
|
||||||
// Send to all server members except those who muted this channel
|
// 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,
|
rows, err := m.db.QueryContext(ctx,
|
||||||
`SELECT m.user_id FROM members m
|
`SELECT m.user_id FROM members m
|
||||||
LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3
|
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,
|
serverID, authorID, channelID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -100,13 +122,43 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
return
|
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)
|
roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1)
|
||||||
if len(roleMatches) > 0 {
|
if len(roleMatches) > 0 {
|
||||||
for _, match := range roleMatches {
|
for _, match := range roleMatches {
|
||||||
if len(match) > 1 {
|
if len(match) > 1 {
|
||||||
roleID := match[1]
|
roleID := match[1]
|
||||||
// Get users with this role
|
|
||||||
rows, err := m.db.QueryContext(ctx,
|
rows, err := m.db.QueryContext(ctx,
|
||||||
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
|
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
|
||||||
roleID, authorID,
|
roleID, authorID,
|
||||||
@@ -127,12 +179,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the author from mentions
|
|
||||||
delete(mentionedUsers, authorID)
|
delete(mentionedUsers, authorID)
|
||||||
|
|
||||||
// Send push to individually mentioned users
|
|
||||||
for userID := range mentionedUsers {
|
for userID := range mentionedUsers {
|
||||||
// Check if user is in DND status
|
|
||||||
var status string
|
var status string
|
||||||
err := m.db.QueryRowContext(ctx,
|
err := m.db.QueryRowContext(ctx,
|
||||||
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
|
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
|
||||||
@@ -144,7 +193,6 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user muted this channel
|
|
||||||
var level string
|
var level string
|
||||||
err = m.db.QueryRowContext(ctx,
|
err = m.db.QueryRowContext(ctx,
|
||||||
`SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`,
|
`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
|
||||||
|
}
|
||||||
@@ -11,9 +11,15 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
|||||||
w.Header().Set("X-Frame-Options", "DENY")
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
w.Header().Set("Content-Security-Policy",
|
w.Header().Set("Content-Security-Policy",
|
||||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
|
"default-src 'self'; "+
|
||||||
"img-src 'self' https: data:; connect-src 'self' wss: ws:; font-src 'self';")
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob: https://cdn.jsdelivr.net https://unpkg.com; "+
|
||||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(self), geolocation=()")
|
"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)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// CheckPermission reports whether the user has all the required permission bits
|
||||||
// in the given server. ADMINISTRATOR bypasses all checks.
|
// in the given server. ADMINISTRATOR bypasses all checks.
|
||||||
func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID string, required int64) (bool, error) {
|
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)
|
perms, err := c.GetUserPermissions(ctx, serverID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
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
|
// for a specific channel. It layers channel-level overrides on top of server role
|
||||||
// permissions. ADMINISTRATOR bypasses all checks.
|
// permissions. ADMINISTRATOR bypasses all checks.
|
||||||
func (c *Checker) CheckChannelPermission(ctx context.Context, serverID, userID, channelID string, required int64) (bool, error) {
|
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)
|
perms, err := c.GetUserPermissions(ctx, serverID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
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).
|
// 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, `
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
SELECT co.allow_bitflags, co.deny_bitflags
|
SELECT co.allow_bitflags, co.deny_bitflags
|
||||||
FROM channel_overrides co
|
FROM channel_overrides co
|
||||||
WHERE co.channel_id = $1 AND co.target_type = 'role'
|
WHERE co.channel_id = $1 AND co.target_type = 'role'
|
||||||
AND co.target_id IN (
|
AND (
|
||||||
SELECT mr.role_id FROM member_roles mr WHERE mr.user_id = $2 AND mr.server_id = $3
|
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)
|
`, channelID, userID, serverID)
|
||||||
if err != nil {
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
webpush "github.com/SherClockHolmes/webpush-go"
|
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) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.HandleFunc("POST /push/subscribe", h.Subscribe)
|
r.Post("/push/subscribe", h.Subscribe)
|
||||||
r.HandleFunc("POST /push/unsubscribe", h.Unsubscribe)
|
r.Post("/push/unsubscribe", h.Unsubscribe)
|
||||||
r.HandleFunc("GET /push/vapid-public-key", h.GetPublicKey)
|
r.Get("/push/vapid-public-key", h.GetPublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary Get VAPID public key
|
// @Summary Get VAPID public key
|
||||||
@@ -168,17 +167,22 @@ func (h *Handler) SendPush(ctx context.Context, userID string, payload map[strin
|
|||||||
Subscriber: h.vapidSubj,
|
Subscriber: h.vapidSubj,
|
||||||
VAPIDPublicKey: h.vapidPub,
|
VAPIDPublicKey: h.vapidPub,
|
||||||
VAPIDPrivateKey: h.vapidPriv,
|
VAPIDPrivateKey: h.vapidPriv,
|
||||||
TTL: 30,
|
TTL: 86400,
|
||||||
|
Urgency: webpush.UrgencyHigh,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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 {
|
if resp != nil && resp.StatusCode == 410 {
|
||||||
h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
|
h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
|
h.logger.Info("push sent", "user_id", userID, "status", resp.StatusCode, "endpoint", endpoint[:min(60, len(endpoint))])
|
||||||
resp.Body.Close()
|
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)
|
// SendChannelNotification sends a push notification to all server members (except the author)
|
||||||
// when a new message is posted in a channel.
|
// when a new message is posted in a channel.
|
||||||
func (h *Handler) SendChannelNotification(ctx context.Context, channelID, authorID, channelName, content string) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +201,8 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.logger.Info("sending push notifications", "channel", channelName, "author", authorID)
|
||||||
|
|
||||||
body := content
|
body := content
|
||||||
if len(body) > 200 {
|
if len(body) > 200 {
|
||||||
body = body[:200] + "..."
|
body = body[:200] + "..."
|
||||||
@@ -222,6 +228,7 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
sent := 0
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var userID, endpoint, p256dh, auth string
|
var userID, endpoint, p256dh, auth string
|
||||||
if err := rows.Scan(&userID, &endpoint, &p256dh, &auth); err != nil {
|
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,
|
Subscriber: h.vapidSubj,
|
||||||
VAPIDPublicKey: h.vapidPub,
|
VAPIDPublicKey: h.vapidPub,
|
||||||
VAPIDPrivateKey: h.vapidPriv,
|
VAPIDPrivateKey: h.vapidPriv,
|
||||||
TTL: 30,
|
TTL: 86400,
|
||||||
|
Urgency: webpush.UrgencyHigh,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
||||||
@@ -251,23 +259,12 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author
|
|||||||
}
|
}
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
resp.Body.Close()
|
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.
|
// GenerateVAPIDKeys is in cmd/keygen. Use github.com/SherClockHolmes/webpush-go directly.
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.Post("/{messageID}/reactions", h.Add)
|
r.Post("/", h.Add)
|
||||||
r.Delete("/{messageID}/reactions/{emoji}", h.Remove)
|
r.Delete("/{emoji}", h.Remove)
|
||||||
r.Get("/{messageID}/reactions", h.List)
|
r.Get("/", h.List)
|
||||||
}
|
}
|
||||||
|
|
||||||
type addReactionRequest struct {
|
type addReactionRequest struct {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Get("/{serverID}", h.Get)
|
r.Get("/{serverID}", h.Get)
|
||||||
r.Patch("/{serverID}", h.Update)
|
r.Patch("/{serverID}", h.Update)
|
||||||
r.Delete("/{serverID}", h.Delete)
|
r.Delete("/{serverID}", h.Delete)
|
||||||
|
r.Delete("/{serverID}/members/me", h.LeaveServer)
|
||||||
}
|
}
|
||||||
|
|
||||||
type createServerRequest struct {
|
type createServerRequest struct {
|
||||||
@@ -315,3 +316,38 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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"
|
"net/http"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MemberHandler struct {
|
type MemberHandler struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
checker *permissions.Checker
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMemberHandler(db *sql.DB) *MemberHandler {
|
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) {
|
func (h *MemberHandler) RegisterRoutes(r chi.Router) {
|
||||||
r.Get("/members", h.ListMembers)
|
r.Get("/members", h.ListMembers)
|
||||||
|
r.Patch("/members/{userID}", h.UpdateMember)
|
||||||
}
|
}
|
||||||
|
|
||||||
type memberResponse struct {
|
type memberResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
|
Nickname *string `json:"nickname"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
StatusText string `json:"status_text"`
|
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) {
|
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(), `
|
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
|
FROM members m
|
||||||
JOIN users u ON u.id = m.user_id
|
JOIN users u ON u.id = m.user_id
|
||||||
WHERE m.server_id = $1
|
WHERE m.server_id = $1
|
||||||
@@ -65,12 +74,104 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
|||||||
members := make([]memberResponse, 0)
|
members := make([]memberResponse, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m memberResponse
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
if nick.Valid {
|
||||||
|
m.Nickname = &nick.String
|
||||||
|
}
|
||||||
members = append(members, m)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(members)
|
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
|
// 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.
|
// Returns false (and writes the error response) if denied.
|
||||||
func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool {
|
func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool {
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
@@ -89,11 +89,18 @@ func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request
|
|||||||
return false
|
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 {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
return false
|
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 {
|
if !allowed {
|
||||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
return false
|
return false
|
||||||
@@ -358,12 +365,34 @@ func (h *RoleHandler) GetMemberRoles(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(roles)
|
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).
|
// Call this after creating a server (e.g. from the server creation handler).
|
||||||
func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error {
|
func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error {
|
||||||
|
// @everyone
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO roles (server_id, name, permissions, position, is_default)
|
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
||||||
VALUES ($1, '@everyone', $2, 0, TRUE)
|
VALUES ($1, '@everyone', $2, 0, TRUE, NULL)
|
||||||
`, serverID, permissions.DefaultEveryonePermissions)
|
`, 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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,15 +82,8 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ownerID != userID {
|
if ownerID != userID {
|
||||||
// Check permission
|
checker := permissions.NewChecker(h.db)
|
||||||
var hasPerm bool
|
hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||||
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)
|
|
||||||
if err != nil || !hasPerm {
|
if err != nil || !hasPerm {
|
||||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
@@ -223,14 +217,8 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ownerID != userID {
|
if ownerID != userID {
|
||||||
var hasPerm bool
|
checker := permissions.NewChecker(h.db)
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||||
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)
|
|
||||||
if err != nil || !hasPerm {
|
if err != nil || !hasPerm {
|
||||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
@@ -279,14 +267,8 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ownerID != userID {
|
if ownerID != userID {
|
||||||
var hasPerm bool
|
checker := permissions.NewChecker(h.db)
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
hasPerm, err := checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||||
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)
|
|
||||||
if err != nil || !hasPerm {
|
if err != nil || !hasPerm {
|
||||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
"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{
|
var allowedExtensions = map[string]bool{
|
||||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
".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")
|
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) {
|
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-Type", stat.ContentType)
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size))
|
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")
|
w.Header().Set("Content-Disposition", "attachment")
|
||||||
|
}
|
||||||
io.Copy(w, obj)
|
io.Copy(w, obj)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,33 @@ func (c *Client) ListParticipants(roomName string) ([]*livekit.ParticipantInfo,
|
|||||||
return resp.Participants, nil
|
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 {
|
func boolPtr(b bool) *bool {
|
||||||
return &b
|
return &b
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/livekit/protocol/livekit"
|
"github.com/livekit/protocol/livekit"
|
||||||
)
|
)
|
||||||
@@ -15,6 +16,7 @@ type Handler struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
client *Client
|
client *Client
|
||||||
hub *gateway.Hub
|
hub *gateway.Hub
|
||||||
|
checker *permissions.Checker
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *sql.DB, client *Client, hub *gateway.Hub) *Handler {
|
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,
|
db: db,
|
||||||
client: client,
|
client: client,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
|
checker: permissions.NewChecker(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth)
|
||||||
r.Post("/token", h.GetToken)
|
r.Post("/token", h.GetToken)
|
||||||
r.Get("/rooms/{roomID}/participants", h.GetParticipants)
|
r.Get("/rooms/{roomID}/participants", h.GetParticipants)
|
||||||
|
r.Post("/rooms/{roomID}/participants/{userID}/mute", h.MuteParticipant)
|
||||||
}
|
}
|
||||||
|
|
||||||
type tokenRequest struct {
|
type tokenRequest struct {
|
||||||
@@ -148,3 +153,53 @@ func (h *Handler) GetParticipants(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(result)
|
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>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="gruvbox">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<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="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-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Dumpster" />
|
<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" />
|
<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>
|
<title>Dumpster</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
<script>
|
<script>
|
||||||
// Register service worker
|
// Register service worker (only for web, avoid in Tauri to prevent 404 cache bugs)
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
|
if (!('__TAURI_INTERNALS__' in window) && !('__TAURI__' in window)) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -8,7 +8,14 @@
|
|||||||
"name": "dumpster-web",
|
"name": "dumpster-web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"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/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",
|
"livekit-client": "^2.20.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -18,6 +25,7 @@
|
|||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.1.0",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
@@ -745,6 +753,52 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -856,6 +910,25 @@
|
|||||||
"@bufbuild/protobuf": "^1.10.0"
|
"@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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -1299,6 +1372,236 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -1360,6 +1663,23 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1399,6 +1719,16 @@
|
|||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -1534,9 +1864,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.40",
|
"version": "2.10.42",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
|
||||||
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
|
"integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1573,9 +1903,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.4",
|
"version": "4.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
|
||||||
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
"integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1593,10 +1923,10 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.38",
|
"baseline-browser-mapping": "^2.10.42",
|
||||||
"caniuse-lite": "^1.0.30001799",
|
"caniuse-lite": "^1.0.30001800",
|
||||||
"electron-to-chromium": "^1.5.376",
|
"electron-to-chromium": "^1.5.387",
|
||||||
"node-releases": "^2.0.48",
|
"node-releases": "^2.0.50",
|
||||||
"update-browserslist-db": "^1.2.3"
|
"update-browserslist-db": "^1.2.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1617,9 +1947,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001799",
|
"version": "1.0.30001802",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz",
|
||||||
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
|
"integrity": "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1847,12 +2177,27 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.380",
|
"version": "1.5.387",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
|
||||||
"integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==",
|
"integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/es-errors": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
@@ -2002,6 +2347,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@@ -4129,9 +4480,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4210,6 +4561,13 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/unified": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||||
|
|||||||
@@ -7,10 +7,18 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/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",
|
"livekit-client": "^2.20.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -20,6 +28,7 @@
|
|||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.1.0",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@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: 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",
|
"short_name": "Dumpster",
|
||||||
"description": "A chaotic, self-hosted Discord-like platform",
|
"description": "A chaotic, self-hosted Discord-like platform",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"display_override": ["window-controls-overlay", "standalone"],
|
||||||
|
"orientation": "any",
|
||||||
"background_color": "#282828",
|
"background_color": "#282828",
|
||||||
"theme_color": "#282828",
|
"theme_color": "#282828",
|
||||||
"orientation": "any",
|
"categories": ["social", "communication"],
|
||||||
|
"lang": "en",
|
||||||
|
"dir": "ltr",
|
||||||
"icons": [
|
"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",
|
"src": "/icons/icon-192.png",
|
||||||
"sizes": "192x192",
|
"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"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-512.png",
|
"src": "/icons/icon-512.png",
|
||||||
"sizes": "512x512",
|
"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>
|
||||||
@@ -1,95 +1,111 @@
|
|||||||
const CACHE_NAME = 'dumpster-v1';
|
// Dumpster PWA Service Worker
|
||||||
const STATIC_ASSETS = [
|
// 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',
|
'/offline.html',
|
||||||
'/manifest.json',
|
'/favicon.png',
|
||||||
|
'/icons/icon-192.png',
|
||||||
|
'/icons/icon-512.png',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Install: pre-cache shell and skip waiting
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
caches.open(CACHE_VERSION).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting())
|
||||||
return cache.addAll(STATIC_ASSETS);
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Activate: clean old caches and claim clients
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
caches
|
||||||
return Promise.all(
|
.keys()
|
||||||
cacheNames
|
.then((names) => Promise.all(names.filter((n) => n !== CACHE_VERSION).map((n) => caches.delete(n))))
|
||||||
.filter((name) => name !== CACHE_NAME)
|
.then(() => self.clients.claim())
|
||||||
.map((name) => caches.delete(name))
|
|
||||||
);
|
);
|
||||||
})
|
|
||||||
);
|
|
||||||
self.clients.claim();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch handler
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
// Skip non-GET requests
|
|
||||||
if (event.request.method !== 'GET') return;
|
if (event.request.method !== 'GET') return;
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
// Skip API requests
|
// Skip API, WebSocket, and cross-origin requests
|
||||||
if (event.request.url.includes('/api/')) return;
|
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return;
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
// Skip WebSocket
|
// Navigation requests: network-first, offline fallback
|
||||||
if (event.request.url.includes('/ws')) return;
|
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(
|
event.respondWith(
|
||||||
caches.match(event.request).then((cached) => {
|
caches.match(event.request).then((cached) => {
|
||||||
// Return cached version, fetch new one in background
|
const fetchPromise = fetch(event.request)
|
||||||
const fetchPromise = fetch(event.request).then((response) => {
|
.then((response) => {
|
||||||
// Only cache successful responses
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const clone = response.clone();
|
const clone = response.clone();
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
caches.open(CACHE_VERSION).then((c) => c.put(event.request, clone));
|
||||||
cache.put(event.request, clone);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}).catch(() => {
|
})
|
||||||
// Return cached if fetch fails
|
.catch(() => cached);
|
||||||
return cached;
|
|
||||||
});
|
|
||||||
|
|
||||||
return cached || fetchPromise;
|
return cached || fetchPromise;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Push notifications
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
if (!event.data) return;
|
let data = { title: 'Dumpster', body: '', url: '/', tag: 'dumpster-notification' };
|
||||||
|
try {
|
||||||
const data = event.data.json();
|
if (event.data) {
|
||||||
const options = {
|
data = { ...data, ...event.data.json() };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
data.body = event.data ? event.data.text() : '';
|
||||||
|
}
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, {
|
||||||
body: data.body,
|
body: data.body,
|
||||||
icon: '/icons/icon-192.png',
|
icon: '/icons/icon-192.png',
|
||||||
badge: '/icons/badge.png',
|
badge: '/icons/icon-192.png',
|
||||||
vibrate: [100, 50, 100],
|
vibrate: [200, 100, 200],
|
||||||
data: {
|
tag: data.tag,
|
||||||
url: data.url || '/',
|
renotify: true,
|
||||||
},
|
data: { url: data.url },
|
||||||
};
|
})
|
||||||
|
|
||||||
event.waitUntil(
|
|
||||||
self.registration.showNotification(data.title || 'Dumpster', options)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notification click: focus or open window
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => {
|
||||||
// Focus existing window if available
|
for (const client of list) {
|
||||||
for (const client of clientList) {
|
|
||||||
if (client.url.includes(event.notification.data.url) && 'focus' in client) {
|
if (client.url.includes(event.notification.data.url) && 'focus' in client) {
|
||||||
return client.focus();
|
return client.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Otherwise open new window
|
|
||||||
return clients.openWindow(event.notification.data.url);
|
return clients.openWindow(event.notification.data.url);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "app"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
license = ""
|
||||||
|
repository = ""
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "app_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.6.3", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
log = "0.4"
|
||||||
|
tauri = { version = "2.11.3", features = [] }
|
||||||
|
tauri-plugin-log = "2"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "enables the default permissions",
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |