mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 16:46:08 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a598aa8a8 | ||
|
|
ed7d0a73a3 | ||
|
|
6af85559cb | ||
|
|
43f1b97244 | ||
|
|
9baa3ff566 | ||
|
|
0330f24e45 | ||
|
|
ac98845c69 | ||
|
|
d4f540285b | ||
|
|
3896640ac7 | ||
|
|
29bfd87d8b | ||
|
|
b0adb55b8f | ||
|
|
24a5bdb9a1 | ||
|
|
5391d2cb1c | ||
|
|
62996f85e9 | ||
|
|
ac5348dbb0 | ||
|
|
ad5627a4bb | ||
|
|
c7a5829fac | ||
|
|
a0d9d86956 | ||
|
|
0c16f44db6 | ||
|
|
fb4f6c34ed | ||
|
|
8647b05c12 | ||
|
|
e2bab3d0d9 | ||
|
|
6295831045 | ||
|
|
045515369c | ||
|
|
fe0e31f9a0 | ||
|
|
3273e62b37 | ||
|
|
cf5fca5772 | ||
|
|
38df05df41 | ||
|
|
760693befe | ||
|
|
241fee67e8 | ||
|
|
0e3dd932af | ||
|
|
81b09b1af7 | ||
|
|
94b31b6056 | ||
|
|
6e76065dcb | ||
|
|
bdcff74ad5 | ||
|
|
27a25b1b43 | ||
|
|
28c4b2993f | ||
|
|
3c760b0bd8 | ||
|
|
c2a8e9cfc8 | ||
|
|
45fd382b13 | ||
|
|
039390c8b0 | ||
|
|
c5be63db25 | ||
|
|
993bb1f973 | ||
|
|
94f968ccf9 | ||
|
|
4f96b8d986 | ||
|
|
fbb2958fdd | ||
|
|
62e9d7fff5 |
@@ -0,0 +1,39 @@
|
|||||||
|
# EchoHub Server Configuration
|
||||||
|
# Copy this file to .env and customize as needed: cp .env.example .env
|
||||||
|
# These override appsettings.json via ASP.NET Core's configuration hierarchy.
|
||||||
|
|
||||||
|
# ── Server ───────────────────────────────────────────────────────────
|
||||||
|
Server__Name=My EchoHub Server
|
||||||
|
Server__Description=A self-hosted EchoHub chat server
|
||||||
|
Server__PublicServer=false
|
||||||
|
# Server__PublicHost=echohub.example.com
|
||||||
|
# Server__Admins__0=adminUsername
|
||||||
|
|
||||||
|
# ── JWT ──────────────────────────────────────────────────────────────
|
||||||
|
# Auto-generated on first run if left empty. Only set if you need a stable secret across containers.
|
||||||
|
# Jwt__Secret=
|
||||||
|
# Jwt__Issuer=EchoHub.Server
|
||||||
|
# Jwt__Audience=EchoHub.Client
|
||||||
|
|
||||||
|
# ── Encryption ───────────────────────────────────────────────────────
|
||||||
|
# Auto-generated on first run if left empty.
|
||||||
|
# Encryption__Key=
|
||||||
|
# Encryption__EncryptDatabase=false
|
||||||
|
|
||||||
|
# ── Storage ──────────────────────────────────────────────────────────
|
||||||
|
# Defaults are set in the Dockerfile to use /app/data for persistence.
|
||||||
|
# Storage__CleanupIntervalHours=1
|
||||||
|
# Storage__RetentionDays=30
|
||||||
|
|
||||||
|
# ── IRC Gateway ──────────────────────────────────────────────────────
|
||||||
|
Irc__Enabled=false
|
||||||
|
# Irc__Port=6667
|
||||||
|
# Irc__TlsEnabled=false
|
||||||
|
# Irc__TlsPort=6697
|
||||||
|
# Irc__TlsCertPath=
|
||||||
|
# Irc__TlsCertPassword=
|
||||||
|
# Irc__ServerName=echohub
|
||||||
|
# Irc__Motd=Welcome to EchoHub IRC Gateway!
|
||||||
|
|
||||||
|
# ── Logging ──────────────────────────────────────────────────────────
|
||||||
|
# Serilog__MinimumLevel__Default=Information
|
||||||
@@ -22,12 +22,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
|
|
||||||
- name: Remove submodule NuGet config
|
|
||||||
run: rm -f src/Terminal.Gui/nuget.config
|
|
||||||
|
|
||||||
- name: Setup .NET 10
|
- name: Setup .NET 10
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
@@ -35,7 +29,7 @@ jobs:
|
|||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic --exclude src/Terminal.Gui/
|
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
|
||||||
|
|
||||||
build-and-test:
|
build-and-test:
|
||||||
name: Build & Test
|
name: Build & Test
|
||||||
@@ -44,11 +38,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
|
|
||||||
- name: Remove submodule NuGet config
|
|
||||||
run: rm -f src/Terminal.Gui/nuget.config
|
|
||||||
|
|
||||||
- name: Check for src/ changes
|
- name: Check for src/ changes
|
||||||
id: changes
|
id: changes
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: ghcr.io/huebyte/echohub-server
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
name: Build & Push Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Check for src/ changes
|
||||||
|
id: changes
|
||||||
|
env:
|
||||||
|
BEFORE: ${{ github.event.before }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
echo "src_changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
|
||||||
|
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Read version
|
||||||
|
if: steps.changes.outputs.src_changed == 'true'
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Check if image tag exists
|
||||||
|
if: steps.changes.outputs.src_changed == 'true'
|
||||||
|
id: check_image
|
||||||
|
run: |
|
||||||
|
TAG="${{ steps.version.outputs.tag }}"
|
||||||
|
if docker manifest inspect "${{ env.IMAGE }}:${TAG}" &>/dev/null; then
|
||||||
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_image.outputs.exists == 'false'
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_image.outputs.exists == 'false'
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_image.outputs.exists == 'false'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_image.outputs.exists == 'false'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./src
|
||||||
|
file: ./src/EchoHub.Server/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE }}:latest
|
||||||
|
${{ env.IMAGE }}:${{ steps.version.outputs.tag }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=EchoHub Server
|
||||||
|
org.opencontainers.image.description=Self-hosted IRC-style chat server
|
||||||
|
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||||
|
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Skip notice
|
||||||
|
if: steps.changes.outputs.src_changed != 'true' || steps.check_image.outputs.exists == 'true'
|
||||||
|
run: echo "⏭️ Skipped — no src/ changes or image tag already exists."
|
||||||
@@ -16,12 +16,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
|
|
||||||
- name: Remove submodule NuGet config
|
|
||||||
run: rm -f src/Terminal.Gui/nuget.config
|
|
||||||
|
|
||||||
- name: Setup .NET 10
|
- name: Setup .NET 10
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
|
|
||||||
- name: Remove submodule NuGet config
|
|
||||||
run: rm -f src/Terminal.Gui/nuget.config
|
|
||||||
|
|
||||||
- name: Check for src/ changes
|
- name: Check for src/ changes
|
||||||
id: changes
|
id: changes
|
||||||
@@ -76,6 +71,10 @@ jobs:
|
|||||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true -o publish/server-osx-arm64
|
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true -o publish/server-osx-arm64
|
||||||
|
|
||||||
|
- name: Publish Server linux-arm64
|
||||||
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
|
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-arm64 --self-contained true -o publish/server-linux-arm64
|
||||||
|
|
||||||
- name: Publish Client win-x64
|
- name: Publish Client win-x64
|
||||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64
|
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64
|
||||||
@@ -92,6 +91,10 @@ jobs:
|
|||||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -o publish/client-osx-arm64
|
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -o publish/client-osx-arm64
|
||||||
|
|
||||||
|
- name: Publish Client linux-arm64
|
||||||
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
|
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-arm64 --self-contained true -o publish/client-linux-arm64
|
||||||
|
|
||||||
- name: Zip artifacts
|
- name: Zip artifacts
|
||||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
run: |
|
run: |
|
||||||
@@ -100,10 +103,12 @@ jobs:
|
|||||||
zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/
|
zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/
|
||||||
zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/
|
zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/
|
||||||
zip -r ../EchoHub-Server-osx-arm64.zip server-osx-arm64/
|
zip -r ../EchoHub-Server-osx-arm64.zip server-osx-arm64/
|
||||||
|
zip -r ../EchoHub-Server-linux-arm64.zip server-linux-arm64/
|
||||||
zip -r ../EchoHub-Client-win-x64.zip client-win-x64/
|
zip -r ../EchoHub-Client-win-x64.zip client-win-x64/
|
||||||
zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/
|
zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/
|
||||||
zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/
|
zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/
|
||||||
zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/
|
zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/
|
||||||
|
zip -r ../EchoHub-Client-linux-arm64.zip client-linux-arm64/
|
||||||
|
|
||||||
- name: Build release notes
|
- name: Build release notes
|
||||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
@@ -144,9 +149,30 @@ jobs:
|
|||||||
EchoHub-Server-linux-x64.zip \
|
EchoHub-Server-linux-x64.zip \
|
||||||
EchoHub-Server-osx-x64.zip \
|
EchoHub-Server-osx-x64.zip \
|
||||||
EchoHub-Server-osx-arm64.zip \
|
EchoHub-Server-osx-arm64.zip \
|
||||||
|
EchoHub-Server-linux-arm64.zip \
|
||||||
EchoHub-Client-win-x64.zip \
|
EchoHub-Client-win-x64.zip \
|
||||||
EchoHub-Client-linux-x64.zip \
|
EchoHub-Client-linux-x64.zip \
|
||||||
EchoHub-Client-osx-x64.zip \
|
EchoHub-Client-osx-x64.zip \
|
||||||
EchoHub-Client-osx-arm64.zip
|
EchoHub-Client-osx-arm64.zip \
|
||||||
|
EchoHub-Client-linux-arm64.zip
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Publish Chocolatey package
|
||||||
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
CHECKSUM=$(sha256sum EchoHub-Client-win-x64.zip | awk '{print $1}')
|
||||||
|
|
||||||
|
# Stamp version and checksum into package templates
|
||||||
|
sed -i "s/__VERSION__/$VERSION/g" packaging/choco/echohub.nuspec
|
||||||
|
sed -i "s/__VERSION__/$VERSION/g" packaging/choco/tools/chocolateyInstall.ps1
|
||||||
|
sed -i "s/__CHECKSUM64__/$CHECKSUM/g" packaging/choco/tools/chocolateyInstall.ps1
|
||||||
|
|
||||||
|
# Build and push the package
|
||||||
|
mkdir -p /tmp/choco-out
|
||||||
|
cd packaging/choco
|
||||||
|
choco pack echohub.nuspec --output-directory /tmp/choco-out
|
||||||
|
choco push /tmp/choco-out/echohub.*.nupkg --source https://push.chocolatey.org/ --api-key "$CHOCO_API_KEY"
|
||||||
|
env:
|
||||||
|
CHOCO_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[submodule "src/Terminal.Gui"]
|
|
||||||
path = src/Terminal.Gui
|
|
||||||
url = https://github.com/HueByte/Terminal.Gui.git
|
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
// Allow inline HTML (docfx uses it)
|
// Allow inline HTML (docfx uses it)
|
||||||
"MD033": false,
|
"MD033": false,
|
||||||
// Allow bare URLs
|
// Allow bare URLs
|
||||||
"MD034": false
|
"MD034": false,
|
||||||
|
// Allow compact table pipe style (flow docs use compact tables)
|
||||||
|
"MD060": false
|
||||||
},
|
},
|
||||||
|
|
||||||
"globs": ["**/*.md"],
|
"globs": ["**/*.md"],
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
echohub-server:
|
||||||
|
build:
|
||||||
|
context: ./src
|
||||||
|
dockerfile: EchoHub.Server/Dockerfile
|
||||||
|
# image: ghcr.io/huebyte/echohub-server:latest # use this instead of build for pre-built images
|
||||||
|
container_name: echohub-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
# - "6667:6667" # IRC (plain, no encryption)
|
||||||
|
# - "6697:6697" # IRC (TLS encrypted, preferred)
|
||||||
|
volumes:
|
||||||
|
- echohub-data:/app/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
echohub-data:
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Docker
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # create your config
|
||||||
|
docker compose up -d # start the server
|
||||||
|
```
|
||||||
|
|
||||||
|
On first run the server automatically generates JWT and encryption keys, creates the database, and seeds a `#general` channel. Connect with the EchoHub client to `http://localhost:5000`.
|
||||||
|
|
||||||
|
### Using a Pre-built Image
|
||||||
|
|
||||||
|
Instead of building locally, you can pull from GHCR. In `docker-compose.yml`, replace the `build` block:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
echohub-server:
|
||||||
|
image: ghcr.io/huebyte/echohub-server:latest
|
||||||
|
# build:
|
||||||
|
# context: ./src
|
||||||
|
# dockerfile: EchoHub.Server/Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All settings are configured through the `.env` file. These are ASP.NET Core environment variables that override `appsettings.json`.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Server__Name` | My EchoHub Server | Display name for your server |
|
||||||
|
| `Server__Description` | A self-hosted EchoHub chat server | Server description |
|
||||||
|
| `Server__PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) |
|
||||||
|
| `Server__PublicHost` | *(empty)* | Public address for the directory listing |
|
||||||
|
| `Server__Admins__0` | *(empty)* | Admin username (use `__1`, `__2` for more) |
|
||||||
|
| `Jwt__Secret` | *(auto-generated)* | JWT signing key. Auto-generated on first run |
|
||||||
|
| `Encryption__Key` | *(auto-generated)* | AES encryption key. Auto-generated on first run |
|
||||||
|
| `Encryption__EncryptDatabase` | `false` | Encrypt message content in the database |
|
||||||
|
| `Storage__CleanupIntervalHours` | `1` | How often to clean expired uploads |
|
||||||
|
| `Storage__RetentionDays` | `30` | Days to keep uploaded files |
|
||||||
|
| `Irc__Enabled` | `false` | Enable the IRC gateway |
|
||||||
|
| `Irc__Port` | `6667` | IRC plain-text port |
|
||||||
|
| `Irc__TlsEnabled` | `false` | Enable IRC over TLS |
|
||||||
|
| `Irc__TlsPort` | `6697` | IRC TLS port |
|
||||||
|
| `Irc__ServerName` | `echohub` | IRC server name shown to clients |
|
||||||
|
| `Irc__Motd` | Welcome to EchoHub IRC Gateway! | Message of the day |
|
||||||
|
| `Serilog__MinimumLevel__Default` | `Information` | Log level (`Debug`, `Warning`, etc.) |
|
||||||
|
|
||||||
|
## Persistent Data
|
||||||
|
|
||||||
|
All server state lives in a single Docker volume mounted at `/app/data`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/app/data/
|
||||||
|
├── appsettings.json # generated config with JWT/encryption keys
|
||||||
|
├── echohub.db # SQLite database
|
||||||
|
├── uploads/ # uploaded files and avatars
|
||||||
|
└── logs/ # rolling log files (14-day retention)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# stop the server to ensure a consistent snapshot
|
||||||
|
docker compose stop
|
||||||
|
# copy the data volume to a local directory
|
||||||
|
docker cp echohub-server:/app/data ./backup
|
||||||
|
docker compose start
|
||||||
|
```
|
||||||
|
|
||||||
|
## IRC Gateway
|
||||||
|
|
||||||
|
To enable IRC, set these in your `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
Irc__Enabled=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Then uncomment the port in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
- "6697:6697" # IRC (TLS encrypted, preferred)
|
||||||
|
```
|
||||||
|
|
||||||
|
For TLS, also set:
|
||||||
|
|
||||||
|
```env
|
||||||
|
Irc__TlsEnabled=true
|
||||||
|
Irc__TlsCertPath=/app/data/cert.pfx
|
||||||
|
Irc__TlsCertPassword=your_password
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount your certificate into the data volume or bind-mount it directly.
|
||||||
|
|
||||||
|
IRC users must have an existing EchoHub account. See [Getting Started](getting-started.md#connect-via-irc) for client connection examples.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# if using pre-built images
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# if building locally
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Data persists across updates since it lives in the named volume.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Port already in use** -- Another process is using port 5000. Change the host port in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "8080:5000" # access via http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**Permission denied on volume** -- The container runs as a non-root `echohub` user (UID 999). If using bind mounts instead of named volumes, ensure the directory is writable.
|
||||||
|
|
||||||
|
**View logs** -- Check the container output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f echohub-server
|
||||||
|
```
|
||||||
|
|
||||||
|
File-based logs are also available inside the volume at `/app/data/logs/`.
|
||||||
@@ -1,29 +1,60 @@
|
|||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
## Prerequisites
|
## Install the Client
|
||||||
|
|
||||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
|
### Windows (Chocolatey)
|
||||||
|
|
||||||
Or grab a self-contained binary from [Releases](https://github.com/HueByte/EchoHub/releases) -- no runtime needed.
|
```bash
|
||||||
|
choco install echohub
|
||||||
|
```
|
||||||
|
|
||||||
## Run the Server
|
### Linux / macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To install a specific version or to a custom directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSfL .../install.sh | sh -s -- --version 0.2.8
|
||||||
|
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Download
|
||||||
|
|
||||||
|
Grab a self-contained binary from [Releases](https://github.com/HueByte/EchoHub/releases) -- no runtime needed.
|
||||||
|
|
||||||
|
## Host a Server
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
The quickest way to host a server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Docker guide](docker.md) for configuration, pre-built images, and more.
|
||||||
|
|
||||||
|
### From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run --project src/EchoHub.Server
|
dotnet run --project src/EchoHub.Server
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download).
|
||||||
|
|
||||||
On first run, the server automatically:
|
On first run, the server automatically:
|
||||||
|
|
||||||
1. Creates `appsettings.json` from the example config
|
1. Creates `appsettings.json` from the example config
|
||||||
2. Generates a secure JWT secret
|
2. Generates a secure JWT secret
|
||||||
3. Creates the SQLite database with a `#general` channel
|
3. Creates the SQLite database with a `#general` channel
|
||||||
|
|
||||||
## Run the Client
|
## Usage
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet run --project src/EchoHub.Client
|
|
||||||
```
|
|
||||||
|
|
||||||
|
After installing the client, run `echohub` (or `dotnet run --project src/EchoHub.Client` from source).
|
||||||
Connect to a server, register an account, and start chatting.
|
Connect to a server, register an account, and start chatting.
|
||||||
|
|
||||||
## Connect via IRC
|
## Connect via IRC
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
- name: Getting Started
|
- name: Getting Started
|
||||||
href: getting-started.md
|
href: getting-started.md
|
||||||
|
- name: Docker
|
||||||
|
href: docker.md
|
||||||
- name: Architecture
|
- name: Architecture
|
||||||
href: architecture.md
|
href: architecture.md
|
||||||
- name: Encryption
|
- name: Encryption
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix
|
||||||
|
- [v0.2.7](v0.2.7.md) - User List Fix & Terminal.Gui NuGet Migration
|
||||||
|
- [v0.2.6](v0.2.6.md) - Major Refactoring & Code Organization
|
||||||
- [v0.2.5](v0.2.5.md) - Session Persistence, Auto-Updates, Audio & Transparent Theme
|
- [v0.2.5](v0.2.5.md) - Session Persistence, Auto-Updates, Audio & Transparent Theme
|
||||||
- [v0.2.4](v0.2.4.md) - E2E Message Encryption
|
- [v0.2.4](v0.2.4.md) - E2E Message Encryption
|
||||||
- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
|
- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.8
|
||||||
|
href: v0.2.8.md
|
||||||
|
- name: v0.2.7
|
||||||
|
href: v0.2.7.md
|
||||||
|
- name: v0.2.6
|
||||||
|
href: v0.2.6.md
|
||||||
- name: v0.2.5
|
- name: v0.2.5
|
||||||
href: v0.2.5.md
|
href: v0.2.5.md
|
||||||
- name: v0.2.4
|
- name: v0.2.4
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
- Moved `AsyncRunner` from project root to `Services/` with updated namespace
|
- Moved `AsyncRunner` from project root to `Services/` with updated namespace
|
||||||
- Renamed `ColorHelper` → `HexColorHelper` to avoid namespace collision with Terminal.Gui's `ColorHelper` NuGet dependency
|
- Renamed `ColorHelper` → `HexColorHelper` to avoid namespace collision with Terminal.Gui's `ColorHelper` NuGet dependency
|
||||||
- Moved `hue_icon.ico` to `Client/Assets/`, removed duplicate from Server (Server now references shared icon via relative path)
|
- Moved `hue_icon.ico` to `Client/Assets/`, removed duplicate from Server (Server now references shared icon via relative path)
|
||||||
|
- Add hex color parsing helper and implement custom list sources for channels and users
|
||||||
|
- Add dialogs for connection, channel creation, profile editing, and status management
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# v0.2.7
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- Fix user list empty on initial connect — `FetchAndUpdateOnlineUsers` was called before `InvokeUI` set the current channel, causing an early return
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- Switch Terminal.Gui from local fork submodule back to NuGet package (`2.0.0-develop.5039`) — transparent color PR merged upstream
|
||||||
|
- Remove Terminal.Gui submodule, `.gitmodules`, and root `nuget.config` workaround
|
||||||
|
- Remove `rm -f` submodule nuget.config steps and `submodules: recursive` from all 3 CI workflows (ci, docs, release)
|
||||||
|
- Remove `--exclude src/Terminal.Gui/` from format check
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# v0.2.8
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- Fix memory leak — `HttpResponseMessage` objects never disposed in `ApiClient`, leaking TCP connections and content buffers on every API call (especially on failed connection attempts)
|
||||||
|
- Fix 401 retry leak — `AuthenticatedGetAsync`/`AuthenticatedRequestAsync` leaked the original response when retrying after token refresh
|
||||||
|
- Fix connection failure cleanup — `ConnectionManager.ConnectAsync` now properly disposes `ApiClient` and `EchoHubConnection` on any failure path (previously only cleaned up on saved-token auth failures)
|
||||||
|
- Fix IRC gateway sending UTF-8 BOM on first message, breaking CAP negotiation and SASL auth for all clients
|
||||||
|
- Handle `AUTHENTICATE *` (SASL abort) instead of crashing on invalid base64
|
||||||
|
- Fix userlist not refreshing when creating a new channel — now fetches online users after channel creation
|
||||||
|
- Fix unmute timer not working — add `MuteExpirationService` background job that proactively unmutes users when their timed mute expires (previously only checked on message send)
|
||||||
|
- Fix missing space between mod/admin role icon and username in the userlist
|
||||||
|
- Fix invisible users still visible in the userlist — `GetOnlineUsersAsync` now filters invisible users; server skips `UserJoined` broadcast for invisible users
|
||||||
|
- Fix invisible→online transition — user reappears in cached userlist when switching from invisible back to a visible status
|
||||||
|
- Fix thread safety — `_channelUsers` presence cache now protected by `Lock` to prevent races between SignalR events and background fetches
|
||||||
|
- Fix `@mention` regex matching email addresses and `#channel` regex matching hex colors / issue numbers — both now use lookbehind and letter-requirement guards
|
||||||
|
- Fix `ParseThemeColor` accepting non-hex characters — now validates `[0-9a-fA-F]` digits
|
||||||
|
- Fix ghost channel when trying to join a channel that doesn't exist
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- Add Docker support for EchoHub.Server — `docker compose up -d` for easy self-hosting with persistent volume for database, uploads, and logs
|
||||||
|
- IRC account creation — connecting with a new username auto-registers the account (PASS and SASL PLAIN)
|
||||||
|
- Auto-updater rollback — pre-update backup created automatically before each update; restore via File > Rollback menu or `--rollback` CLI flag
|
||||||
|
- Update failure recovery — if an update fails mid-extraction, offers to restore from the backup immediately
|
||||||
|
- Defensive Unix permission check — verify execute permission on startup after auto-update (defense-in-depth)
|
||||||
|
- Clickable usernames — press Enter on a username in the userlist or message sender to view their profile
|
||||||
|
- Clickable @mentions — press Enter on a message containing `@username` to open that user's profile
|
||||||
|
- Clickable #channels — press Enter on a message containing `#channel` to join/switch to that channel; `#channel` references are now highlighted in chat
|
||||||
|
- Embed theme colors — embed vertical border line now uses the source site's `theme-color` meta tag when available
|
||||||
|
- Stateful userlist — user presence is cached per channel and updated incrementally via SignalR events instead of re-fetching the full list on every join/leave/status change
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Restrict file auto-open — only safe file types (video, PDF, text) are opened with the system default app; all other files are downloaded to temp without executing (prevents script execution via `.bat`, `.exe`, etc.)
|
||||||
|
|
||||||
|
## Refactoring
|
||||||
|
|
||||||
|
- Extract `IUserService`/`UserService` — consolidate user registration, authentication, and profile management into a dedicated service, eliminating duplicated logic between `AuthController` and `ChatService`
|
||||||
|
- IRC gateway now checks ban status during authentication (previously skipped)
|
||||||
|
- EchoHubSpace directory updates — server now only sends user count when it actually changes instead of every 30 seconds
|
||||||
|
- `ChatHub.JoinChannel` now returns `JoinChannelResult` instead of `List<MessageDto>` to allow for better error handling
|
||||||
|
|
||||||
|
## Distribution
|
||||||
|
|
||||||
|
- Add Chocolatey package — `choco install echohub` for Windows users, auto-published from CI on each release
|
||||||
|
- Add Linux/macOS install script — `curl -sSfL .../install.sh | sh` with automatic OS/arch detection
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Add Flows section — Mermaid sequence diagrams documenting all major request/event flows (auth, connection, messaging, channels, moderation, file upload, link embeds, server directory) with inline code references
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
- Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release
|
||||||
|
- Add `linux-arm64` builds to release pipeline — server and client binaries for ARM Linux (Raspberry Pi, cloud ARM instances)
|
||||||
|
- Automate Chocolatey package publishing in release workflow (checksum calculation, pack, push)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Authentication
|
||||||
|
|
||||||
|
## User Registration
|
||||||
|
|
||||||
|
A new user creates an account on a server. The client sends credentials via REST,
|
||||||
|
the server hashes the password, issues JWT tokens, and the client stores the
|
||||||
|
refresh token for "Remember Me" sessions.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as ConnectDialog
|
||||||
|
participant AO as AppOrchestrator
|
||||||
|
participant CM as ConnectionManager
|
||||||
|
participant API as ApiClient
|
||||||
|
participant Auth as AuthController
|
||||||
|
participant US as UserService
|
||||||
|
participant JWT as JwtTokenService
|
||||||
|
participant DB as SQLite
|
||||||
|
|
||||||
|
UI->>AO: ConnectDialogResult(IsRegister: true)
|
||||||
|
AO->>CM: ConnectAsync(dialogResult)
|
||||||
|
CM->>API: RegisterAsync(username, password)
|
||||||
|
API->>Auth: POST /api/auth/register
|
||||||
|
Auth->>US: RegisterUserAsync(username, password, displayName)
|
||||||
|
US->>US: Validate (regex, length, uniqueness)
|
||||||
|
US->>DB: INSERT User (BCrypt hash)
|
||||||
|
US-->>Auth: UserOperationResult.Success
|
||||||
|
Auth->>JWT: GenerateAccessToken(user)
|
||||||
|
JWT-->>Auth: (token, expiresAt) [15 min]
|
||||||
|
Auth->>JWT: GenerateRefreshToken()
|
||||||
|
JWT-->>Auth: Base64 random (64 bytes)
|
||||||
|
Auth->>DB: INSERT RefreshToken (SHA256 hash)
|
||||||
|
Auth-->>API: LoginResponse
|
||||||
|
API->>API: SetTokens() — store in memory + set Bearer header
|
||||||
|
API-->>CM: LoginResponse
|
||||||
|
CM->>CM: Wire OnTokensRefreshed for config persistence
|
||||||
|
CM->>CM: Continue to connection setup (see Connection Flow)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| Dialog UI | `src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs` | Lines 251-268 (register handler) |
|
||||||
|
| Orchestrator entry | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 550-592 (`HandleConnect`) |
|
||||||
|
| ConnectionManager auth | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 74-76 (register branch) |
|
||||||
|
| ApiClient register | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 32-43 (`RegisterAsync`) |
|
||||||
|
| AuthController register | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 28-49 |
|
||||||
|
| UserService register | `src/EchoHub.Server/Services/UserService.cs` | Lines 20-59 (`RegisterUserAsync`) |
|
||||||
|
| JWT generation | `src/EchoHub.Server/Auth/JwtTokenService.cs` | Lines 30-53 (access), 80-86 (refresh) |
|
||||||
|
| Token persistence | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 1039-1053 (`SaveServerToConfig`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Login
|
||||||
|
|
||||||
|
Returning user authenticates with username/password or a saved refresh token.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as ConnectDialog
|
||||||
|
participant CM as ConnectionManager
|
||||||
|
participant API as ApiClient
|
||||||
|
participant Auth as AuthController
|
||||||
|
participant US as UserService
|
||||||
|
participant DB as SQLite
|
||||||
|
|
||||||
|
alt Saved refresh token (Remember Me)
|
||||||
|
UI->>CM: ConnectDialogResult(SavedRefreshToken: "...")
|
||||||
|
CM->>API: LoginWithRefreshTokenAsync()
|
||||||
|
API->>Auth: POST /api/auth/refresh
|
||||||
|
Auth->>DB: Lookup token by SHA256 hash
|
||||||
|
Auth->>DB: Revoke old token, issue new pair
|
||||||
|
Auth-->>API: LoginResponse (rotated tokens)
|
||||||
|
else Username + Password
|
||||||
|
UI->>CM: ConnectDialogResult(IsRegister: false)
|
||||||
|
CM->>API: LoginAsync(username, password)
|
||||||
|
API->>Auth: POST /api/auth/login
|
||||||
|
Auth->>US: AuthenticateUserAsync(username, password)
|
||||||
|
US->>DB: Fetch user, BCrypt.Verify(password, hash)
|
||||||
|
US->>DB: Update LastSeenAt
|
||||||
|
US-->>Auth: UserOperationResult.Success
|
||||||
|
Auth-->>API: LoginResponse
|
||||||
|
end
|
||||||
|
API->>API: SetTokens()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| Login button handler | `src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs` | Lines 214-249 |
|
||||||
|
| Saved token branch | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 69-71 |
|
||||||
|
| Password branch | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 78-80 |
|
||||||
|
| ApiClient login | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 45-56 (`LoginAsync`) |
|
||||||
|
| AuthController login | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 51-72 |
|
||||||
|
| AuthController refresh | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 74-108 |
|
||||||
|
| UserService authenticate | `src/EchoHub.Server/Services/UserService.cs` | Lines 61-83 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token Refresh
|
||||||
|
|
||||||
|
Access tokens expire after 15 minutes. The client auto-refreshes transparently
|
||||||
|
before requests and on 401 responses. Refresh tokens are rotated on each use.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant SR as SignalR / HTTP Request
|
||||||
|
participant API as ApiClient
|
||||||
|
participant Auth as AuthController
|
||||||
|
participant DB as SQLite
|
||||||
|
participant Config as config.json
|
||||||
|
|
||||||
|
SR->>API: GetValidTokenAsync() or HTTP 401
|
||||||
|
API->>API: Token expires within 60s?
|
||||||
|
alt Proactive refresh (SignalR token provider)
|
||||||
|
API->>Auth: POST /api/auth/refresh (old refresh token)
|
||||||
|
else Reactive refresh (HTTP 401 retry)
|
||||||
|
API->>Auth: POST /api/auth/refresh (old refresh token)
|
||||||
|
end
|
||||||
|
Auth->>DB: Lookup by SHA256 hash
|
||||||
|
Auth->>DB: Revoke old refresh token
|
||||||
|
Auth->>DB: INSERT new RefreshToken
|
||||||
|
Auth-->>API: LoginResponse (new token pair)
|
||||||
|
API->>API: SetTokens() — update Bearer header
|
||||||
|
API-->>API: Fire OnTokensRefreshed event
|
||||||
|
API-->>Config: Persist new refresh token (if Remember Me)
|
||||||
|
API->>SR: Retry original request with new token
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| Proactive check | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 110-129 (`GetValidTokenAsync`) |
|
||||||
|
| Reactive 401 retry (GET) | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 338-358 (`AuthenticatedGetAsync`) |
|
||||||
|
| Reactive 401 retry (POST/PUT/DELETE) | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 364-384 (`AuthenticatedRequestAsync`) |
|
||||||
|
| Refresh HTTP call | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 58-71 (`RefreshTokenAsync`) |
|
||||||
|
| SignalR token provider | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Line 37 (`AccessTokenProvider`) |
|
||||||
|
| Server-side rotation | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 74-108 |
|
||||||
|
| Token persistence callback | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 253-264 |
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Channels
|
||||||
|
|
||||||
|
## Channel Creation
|
||||||
|
|
||||||
|
Channels are created via the REST API. Public channels are broadcast to all
|
||||||
|
connected clients.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client as Client (TUI/API)
|
||||||
|
participant CC as ChannelsController
|
||||||
|
participant ChS as ChannelService
|
||||||
|
participant CS as ChatService
|
||||||
|
participant DB as SQLite
|
||||||
|
participant SRB as SignalRBroadcaster
|
||||||
|
participant IRCB as IrcBroadcaster
|
||||||
|
|
||||||
|
Client->>CC: POST /api/channels {name, topic, isPublic}
|
||||||
|
CC->>ChS: CreateChannelAsync(creatorId, name, topic, isPublic)
|
||||||
|
ChS->>ChS: Normalize name (lowercase, trim)
|
||||||
|
ChS->>ChS: Validate format (2-100 chars, regex)
|
||||||
|
ChS->>DB: Check duplicate
|
||||||
|
ChS->>DB: INSERT Channel + ChannelMembership (creator auto-added)
|
||||||
|
ChS-->>CC: ChannelDto
|
||||||
|
|
||||||
|
alt Channel is public
|
||||||
|
CC->>CS: BroadcastChannelUpdatedAsync(channel)
|
||||||
|
CS->>SRB: SendChannelUpdatedAsync(channelDto)
|
||||||
|
SRB->>SRB: Notify all SignalR clients
|
||||||
|
CS->>IRCB: SendChannelUpdatedAsync(channelDto)
|
||||||
|
end
|
||||||
|
|
||||||
|
CC-->>Client: 201 Created (ChannelDto)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| Controller endpoint | `src/EchoHub.Server/Controllers/ChannelsController.cs` | Lines 60-76 |
|
||||||
|
| Channel service create | `src/EchoHub.Server/Services/ChannelService.cs` | Lines 50-90 |
|
||||||
|
| Broadcast updated | `src/EchoHub.Server/Services/ChatService.cs` | Lines 308-309 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Channel Deletion
|
||||||
|
|
||||||
|
Only the channel creator (or admin) can delete a channel. The default channel
|
||||||
|
is protected.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client as Client
|
||||||
|
participant CC as ChannelsController
|
||||||
|
participant ChS as ChannelService
|
||||||
|
participant DB as SQLite
|
||||||
|
|
||||||
|
Client->>CC: DELETE /api/channels/{channel}
|
||||||
|
CC->>ChS: DeleteChannelAsync(channelName, callerId)
|
||||||
|
ChS->>ChS: Reject if default channel
|
||||||
|
ChS->>DB: Lookup channel
|
||||||
|
ChS->>ChS: Verify caller is creator or admin
|
||||||
|
ChS->>DB: DELETE Channel (cascade: messages, memberships)
|
||||||
|
ChS-->>CC: Success
|
||||||
|
CC-->>Client: 204 No Content
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| Controller endpoint | `src/EchoHub.Server/Controllers/ChannelsController.cs` | Lines 94-106 |
|
||||||
|
| Channel service delete | `src/EchoHub.Server/Services/ChannelService.cs` | Lines 119-144 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Joining a Channel
|
||||||
|
|
||||||
|
Both SignalR and IRC clients join channels through `ChatService`. The presence
|
||||||
|
tracker determines if this is a genuinely new join (vs. a second connection) and
|
||||||
|
broadcasts accordingly.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client as Client (SignalR or IRC)
|
||||||
|
participant Entry as ChatHub / IrcCommandHandler
|
||||||
|
participant CS as ChatService
|
||||||
|
participant ChS as ChannelService
|
||||||
|
participant PT as PresenceTracker
|
||||||
|
participant DB as SQLite
|
||||||
|
participant SRB as SignalRBroadcaster
|
||||||
|
participant IRCB as IrcBroadcaster
|
||||||
|
|
||||||
|
Client->>Entry: JoinChannel / JOIN #channel
|
||||||
|
Entry->>CS: JoinChannelAsync(connectionId, userId, username, channel)
|
||||||
|
CS->>ChS: EnsureChannelMembershipAsync(userId, channel)
|
||||||
|
ChS->>DB: INSERT ChannelMembership (if not exists)
|
||||||
|
|
||||||
|
CS->>PT: JoinChannel(username, channel)
|
||||||
|
PT-->>CS: isNewJoin?
|
||||||
|
|
||||||
|
alt First connection in this channel
|
||||||
|
CS->>DB: Fetch UserPresenceDto
|
||||||
|
CS->>CS: BroadcastToAllAsync(SendUserJoinedAsync)
|
||||||
|
par
|
||||||
|
CS->>SRB: SendUserJoinedAsync(channel, user, excludeConn)
|
||||||
|
and
|
||||||
|
CS->>IRCB: SendUserJoinedAsync(channel, user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
CS->>DB: Fetch message history
|
||||||
|
CS-->>Entry: (history, error)
|
||||||
|
Entry-->>Client: History messages
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| SignalR hub join | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 59-81 |
|
||||||
|
| IRC join | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 361-414 |
|
||||||
|
| ChatService join | `src/EchoHub.Server/Services/ChatService.cs` | Lines 96-135 |
|
||||||
|
| Presence join | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 58-70 |
|
||||||
|
| SignalR broadcast | `src/EchoHub.Server/Services/SignalRBroadcaster.cs` | Lines 26-32 |
|
||||||
|
| IRC broadcast | `src/EchoHub.Server.Irc/IrcBroadcaster.cs` | Lines 34-41 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Leaving a Channel
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client as Client
|
||||||
|
participant Entry as ChatHub / IrcCommandHandler
|
||||||
|
participant CS as ChatService
|
||||||
|
participant PT as PresenceTracker
|
||||||
|
participant SRB as SignalRBroadcaster
|
||||||
|
participant IRCB as IrcBroadcaster
|
||||||
|
|
||||||
|
Client->>Entry: LeaveChannel / PART #channel
|
||||||
|
Entry->>CS: LeaveChannelAsync(connectionId, username, channel)
|
||||||
|
CS->>PT: LeaveChannel(username, channel)
|
||||||
|
CS->>CS: BroadcastToAllAsync(SendUserLeftAsync)
|
||||||
|
par
|
||||||
|
CS->>SRB: SendUserLeftAsync(channel, username)
|
||||||
|
and
|
||||||
|
CS->>IRCB: SendUserLeftAsync(channel, username)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| SignalR hub leave | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 83-96 |
|
||||||
|
| IRC part | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 416-435 |
|
||||||
|
| ChatService leave | `src/EchoHub.Server/Services/ChatService.cs` | Lines 137-143 |
|
||||||
|
| Presence leave | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 72-81 |
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Connection
|
||||||
|
|
||||||
|
## SignalR Client Connection
|
||||||
|
|
||||||
|
After authentication, the TUI client establishes a SignalR WebSocket, registers
|
||||||
|
event handlers, joins the default channel, and loads history.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant CM as ConnectionManager
|
||||||
|
participant EHC as EchoHubConnection
|
||||||
|
participant Hub as ChatHub
|
||||||
|
participant CS as ChatService
|
||||||
|
participant PT as PresenceTracker
|
||||||
|
participant DB as SQLite
|
||||||
|
|
||||||
|
CM->>CM: Fetch encryption key (GET /api/server/encryption-key)
|
||||||
|
CM->>EHC: new EchoHubConnection(apiClient, encryption)
|
||||||
|
EHC->>EHC: Build HubConnection (URL + JWT token provider + auto-reconnect)
|
||||||
|
EHC->>EHC: RegisterHandlers() — wire ReceiveMessage, UserJoined, etc.
|
||||||
|
EHC->>Hub: ConnectAsync() → WebSocket handshake
|
||||||
|
Hub->>CS: UserConnectedAsync(connectionId, userId, username)
|
||||||
|
CS->>PT: UserConnected(connectionId, userId, username)
|
||||||
|
CS->>DB: Update user: Status=Online, LastSeenAt=now
|
||||||
|
CM->>CM: Fetch channel list (GET /api/channels)
|
||||||
|
CM->>EHC: JoinChannelAsync("general")
|
||||||
|
EHC->>Hub: InvokeAsync("JoinChannel", "general")
|
||||||
|
Hub->>CS: JoinChannelAsync(connectionId, userId, username, "general")
|
||||||
|
CS->>DB: EnsureChannelMembership
|
||||||
|
CS->>PT: JoinChannel(username, "general")
|
||||||
|
CS->>CS: BroadcastToAllAsync → UserJoined
|
||||||
|
CS->>DB: Fetch message history
|
||||||
|
Hub-->>EHC: List<MessageDto> (encrypted)
|
||||||
|
EHC-->>CM: Decrypted history
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| Connection orchestration | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 58-140 (`ConnectAsync`) |
|
||||||
|
| EchoHubConnection setup | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 29-62 (constructor) |
|
||||||
|
| Handler registration | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 64-122 (`RegisterHandlers`) |
|
||||||
|
| Hub OnConnected | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 31-43 |
|
||||||
|
| ChatService connected | `src/EchoHub.Server/Services/ChatService.cs` | Lines 41-57 |
|
||||||
|
| PresenceTracker connect | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 13-29 |
|
||||||
|
| Join channel (hub) | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 59-81 |
|
||||||
|
| Join channel (service) | `src/EchoHub.Server/Services/ChatService.cs` | Lines 96-135 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IRC Client Connection
|
||||||
|
|
||||||
|
IRC clients connect via TCP, authenticate with PASS/NICK/USER or SASL PLAIN,
|
||||||
|
and auto-join channels. New usernames are auto-registered.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant IRC as IRC Client
|
||||||
|
participant GW as IrcGatewayService
|
||||||
|
participant CH as IrcCommandHandler
|
||||||
|
participant US as UserService
|
||||||
|
participant CS as ChatService
|
||||||
|
participant PT as PresenceTracker
|
||||||
|
|
||||||
|
IRC->>GW: TCP connect (:6667 or :6697 TLS)
|
||||||
|
GW->>GW: Accept + create IrcClientConnection
|
||||||
|
GW->>CH: new IrcCommandHandler(connection, services)
|
||||||
|
GW->>CH: RunAsync() — start read loop
|
||||||
|
|
||||||
|
alt SASL PLAIN
|
||||||
|
IRC->>CH: CAP REQ :sasl
|
||||||
|
CH-->>IRC: CAP ACK :sasl
|
||||||
|
IRC->>CH: AUTHENTICATE PLAIN
|
||||||
|
CH-->>IRC: AUTHENTICATE +
|
||||||
|
IRC->>CH: AUTHENTICATE <base64(\0user\0pass)>
|
||||||
|
CH->>US: AuthenticateUserAsync(user, pass)
|
||||||
|
alt Auth fails → auto-register
|
||||||
|
CH->>US: RegisterUserAsync(user, pass)
|
||||||
|
end
|
||||||
|
CH-->>IRC: 903 :SASL authentication successful
|
||||||
|
else PASS/NICK/USER
|
||||||
|
IRC->>CH: PASS <password>
|
||||||
|
IRC->>CH: NICK <nickname>
|
||||||
|
IRC->>CH: USER <username> 0 * :<realname>
|
||||||
|
CH->>US: AuthenticateUserAsync(nick, pass)
|
||||||
|
alt Auth fails → auto-register
|
||||||
|
CH->>US: RegisterUserAsync(nick, pass)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
CH->>CS: UserConnectedAsync(irc-{guid}, userId, username)
|
||||||
|
CS->>PT: UserConnected(irc-{guid}, userId, username)
|
||||||
|
CH-->>IRC: 001-004 RPL_WELCOME burst + MOTD
|
||||||
|
|
||||||
|
Note over IRC,CH: Client is now ready for JOIN/PART/PRIVMSG
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| TCP listener | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 45-90 (`ExecuteAsync`) |
|
||||||
|
| Client handler | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 92-154 (`HandleClientAsync`) |
|
||||||
|
| Command read loop | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 40-98 (`RunAsync`) |
|
||||||
|
| SASL auth | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 136-207 (`HandleAuthenticateAsync`) |
|
||||||
|
| PASS/NICK/USER | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 209-267 |
|
||||||
|
| Registration completion | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 268-315 (`TryCompleteRegistrationAsync`) |
|
||||||
|
| Cleanup on disconnect | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 136-153 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Disconnect & Presence
|
||||||
|
|
||||||
|
When a client disconnects, the presence tracker determines if the user has any
|
||||||
|
remaining connections. If not, status is set to Invisible and all channels are
|
||||||
|
notified.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client as Client
|
||||||
|
participant Entry as ChatHub / IrcGatewayService
|
||||||
|
participant CS as ChatService
|
||||||
|
participant PT as PresenceTracker
|
||||||
|
participant DB as SQLite
|
||||||
|
participant SRB as SignalRBroadcaster
|
||||||
|
|
||||||
|
Client->>Entry: Disconnect / TCP close
|
||||||
|
Entry->>CS: UserDisconnectedAsync(connectionId)
|
||||||
|
CS->>PT: Get username + channels (before removal)
|
||||||
|
CS->>PT: UserDisconnected(connectionId)
|
||||||
|
PT->>PT: Remove connection from tracking
|
||||||
|
|
||||||
|
alt No remaining connections for user
|
||||||
|
CS->>DB: Update user: Status=Invisible, LastSeenAt=now
|
||||||
|
loop For each channel user was in
|
||||||
|
CS->>CS: BroadcastToAllAsync(SendUserStatusChangedAsync)
|
||||||
|
CS->>SRB: Notify channel members
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| SignalR disconnect | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 45-57 |
|
||||||
|
| IRC cleanup | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 136-153 |
|
||||||
|
| ChatService disconnect | `src/EchoHub.Server/Services/ChatService.cs` | Lines 59-94 |
|
||||||
|
| Presence disconnect | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 31-53 |
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Flows
|
||||||
|
|
||||||
|
This section documents the major request/event flows in EchoHub, showing how data moves between the TUI client, IRC client, server, and database. Each diagram includes code references so you can jump straight to the implementation.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Media & Services
|
||||||
|
|
||||||
|
## File Upload
|
||||||
|
|
||||||
|
Files are uploaded via REST, validated by magic bytes, stored with GUID filenames,
|
||||||
|
and broadcast as a message with a download link.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client as Client
|
||||||
|
participant CC as ChannelsController
|
||||||
|
participant FV as FileValidationHelper
|
||||||
|
participant FS as FileStorageService
|
||||||
|
participant CS as ChatService
|
||||||
|
participant DB as SQLite
|
||||||
|
|
||||||
|
Client->>CC: POST /api/channels/{channel}/upload (multipart)
|
||||||
|
CC->>CC: Check file size limits
|
||||||
|
CC->>FV: IsValidImage(stream) — magic byte check
|
||||||
|
FV->>FV: Read header: JPEG(FFD8FF) / PNG(89504E47) / GIF / WebP(RIFF+WEBP)
|
||||||
|
FV-->>CC: true/false
|
||||||
|
CC->>FS: SaveFileAsync(stream, extension)
|
||||||
|
FS->>FS: Generate GUID filename, write to uploads/
|
||||||
|
FS-->>CC: fileId (GUID)
|
||||||
|
CC->>CC: Determine MessageType (Image/Audio/File)
|
||||||
|
CC->>CS: SendMessageAsync (with file URL + optional ASCII art)
|
||||||
|
CS->>DB: INSERT Message
|
||||||
|
CS->>CS: BroadcastToAllAsync → fan out to clients
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| Upload endpoint | `src/EchoHub.Server/Controllers/ChannelsController.cs` | Lines 108-200 |
|
||||||
|
| File validation | `src/EchoHub.Server/Services/FileValidationHelper.cs` | Lines 15-82 |
|
||||||
|
| File storage | `src/EchoHub.Server/Services/FileStorageService.cs` | Lines 1-47 |
|
||||||
|
| File download | `src/EchoHub.Server/Controllers/FilesController.cs` | Lines 22-53 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Link Embed Resolution
|
||||||
|
|
||||||
|
When a message contains URLs, the server fetches OpenGraph metadata for preview
|
||||||
|
embeds.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant CS as ChatService
|
||||||
|
participant LE as LinkEmbedService
|
||||||
|
participant Web as External Website
|
||||||
|
|
||||||
|
CS->>LE: TryGetEmbedsAsync(messageContent)
|
||||||
|
LE->>LE: Extract URLs via regex
|
||||||
|
LE->>LE: Filter: max URLs per message, skip duplicates
|
||||||
|
|
||||||
|
loop For each URL
|
||||||
|
LE->>LE: Validate: http(s) only, block private IPs
|
||||||
|
LE->>Web: GET URL (timeout + size limit)
|
||||||
|
Web-->>LE: HTML response
|
||||||
|
LE->>LE: Parse og:title, og:description, og:site_name
|
||||||
|
LE->>LE: Parse theme-color meta tag
|
||||||
|
LE->>LE: Fallback to <title> if no og:title
|
||||||
|
end
|
||||||
|
|
||||||
|
LE-->>CS: List<EmbedDto> (or null)
|
||||||
|
Note over CS: Attached to MessageDto before broadcast
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| Entry point | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 28-51 (`TryGetEmbedsAsync`) |
|
||||||
|
| URL extraction | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 145-160 |
|
||||||
|
| Private IP blocking | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 162-181 |
|
||||||
|
| OG tag parsing | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 187-210 |
|
||||||
|
| Theme color parsing | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 117-143 |
|
||||||
|
| ChatService integration | `src/EchoHub.Server/Services/ChatService.cs` | Lines 194-201 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Directory Registration
|
||||||
|
|
||||||
|
Public servers register with the EchoHubSpace directory for discoverability.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant SDS as ServerDirectoryService
|
||||||
|
participant Dir as EchoHubSpace Directory
|
||||||
|
participant PT as PresenceTracker
|
||||||
|
|
||||||
|
SDS->>SDS: Check Server:PublicServer config
|
||||||
|
SDS->>Dir: SignalR connect (echohub.voidcube.cloud/hubs/servers)
|
||||||
|
SDS->>Dir: RegisterServer(name, description, host, userCount)
|
||||||
|
Dir-->>SDS: Registered
|
||||||
|
|
||||||
|
loop Every 30 seconds
|
||||||
|
SDS->>PT: Get online user count
|
||||||
|
alt Count changed
|
||||||
|
SDS->>Dir: UpdateUserCount(count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Dir->>SDS: Ping
|
||||||
|
SDS->>Dir: Heartbeat
|
||||||
|
|
||||||
|
Note over SDS,Dir: Exponential backoff on disconnect (2s → 30s max)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| Service lifecycle | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 29-122 |
|
||||||
|
| Registration | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 195-212 |
|
||||||
|
| User count polling | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 154-187 |
|
||||||
|
| Reconnection backoff | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 77-82, 191 |
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Messaging
|
||||||
|
|
||||||
|
## Sending a Message (SignalR)
|
||||||
|
|
||||||
|
A message typed in the TUI travels through encryption, the SignalR hub,
|
||||||
|
`ChatService` validation, database storage, and fan-out to both SignalR and IRC
|
||||||
|
clients.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as MainWindow (TUI)
|
||||||
|
participant AO as AppOrchestrator
|
||||||
|
participant EHC as EchoHubConnection
|
||||||
|
participant Hub as ChatHub
|
||||||
|
participant CS as ChatService
|
||||||
|
participant LE as LinkEmbedService
|
||||||
|
participant DB as SQLite
|
||||||
|
participant SRB as SignalRBroadcaster
|
||||||
|
participant IRCB as IrcBroadcaster
|
||||||
|
participant Clients as Other Clients
|
||||||
|
|
||||||
|
UI->>AO: OnMessageSubmitted(channel, text)
|
||||||
|
AO->>AO: IsCommand(text)? → No
|
||||||
|
AO->>EHC: SendMessageAsync(channel, text)
|
||||||
|
EHC->>EHC: Encrypt(text) → ciphertext
|
||||||
|
EHC->>Hub: InvokeAsync("SendMessage", channel, ciphertext)
|
||||||
|
Hub->>CS: SendMessageAsync(userId, username, channel, ciphertext)
|
||||||
|
CS->>CS: Decrypt(ciphertext) → plaintext
|
||||||
|
CS->>CS: Validate (length, newlines, channel exists)
|
||||||
|
CS->>DB: Check mute status
|
||||||
|
CS->>LE: TryGetEmbedsAsync(plaintext)
|
||||||
|
LE->>LE: Extract URLs, fetch OG tags
|
||||||
|
LE-->>CS: List<EmbedDto> (or null)
|
||||||
|
CS->>DB: INSERT Message (encrypted at rest)
|
||||||
|
CS->>CS: Re-encrypt plaintext for transport
|
||||||
|
CS->>CS: Build MessageDto with embeds
|
||||||
|
|
||||||
|
par Fan-out to all broadcasters
|
||||||
|
CS->>SRB: SendMessageToChannelAsync(channel, dto)
|
||||||
|
SRB->>Clients: HubContext.Group(channel).ReceiveMessage(dto)
|
||||||
|
and
|
||||||
|
CS->>IRCB: SendMessageToChannelAsync(channel, dto)
|
||||||
|
IRCB->>IRCB: Decrypt → format as PRIVMSG lines
|
||||||
|
IRCB->>Clients: Send to each IRC conn (skip sender)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| Input handler | `src/EchoHub.Client/UI/MainWindow.cs` | Lines 428-449 (`OnInputKeyDown`) |
|
||||||
|
| Orchestrator dispatch | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 631-661 (`HandleMessageSubmitted`) |
|
||||||
|
| Client encrypt + send | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 148-153 (`SendMessageAsync`) |
|
||||||
|
| Hub receive | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 98-111 (`SendMessage`) |
|
||||||
|
| ChatService process | `src/EchoHub.Server/Services/ChatService.cs` | Lines 145-241 (`SendMessageAsync`) |
|
||||||
|
| Mute check | `src/EchoHub.Server/Services/ChatService.cs` | Lines 177-190 |
|
||||||
|
| Link embeds | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 28-51 (`TryGetEmbedsAsync`) |
|
||||||
|
| DB insert | `src/EchoHub.Server/Services/ChatService.cs` | Lines 208-221 |
|
||||||
|
| Broadcast fan-out | `src/EchoHub.Server/Services/ChatService.cs` | Lines 311-324 (`BroadcastToAllAsync`) |
|
||||||
|
| SignalR broadcast | `src/EchoHub.Server/Services/SignalRBroadcaster.cs` | Lines 23-24 |
|
||||||
|
| IRC broadcast | `src/EchoHub.Server.Irc/IrcBroadcaster.cs` | Lines 17-32 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sending a Message (IRC)
|
||||||
|
|
||||||
|
Messages from IRC clients follow the same `ChatService` path but enter as
|
||||||
|
plaintext (no app-layer encryption).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant IRC as IRC Client
|
||||||
|
participant CH as IrcCommandHandler
|
||||||
|
participant CS as ChatService
|
||||||
|
participant DB as SQLite
|
||||||
|
participant SRB as SignalRBroadcaster
|
||||||
|
participant IRCB as IrcBroadcaster
|
||||||
|
|
||||||
|
IRC->>CH: PRIVMSG #channel :Hello world
|
||||||
|
CH->>CH: Parse target + content
|
||||||
|
CH->>CH: IrcToEchoHubChannel("#channel") → "channel"
|
||||||
|
CH->>CS: SendMessageAsync(userId, username, "channel", "Hello world")
|
||||||
|
CS->>CS: Decrypt("Hello world") → passthrough (no $ENC$ prefix)
|
||||||
|
CS->>CS: Validate, check mute, fetch embeds
|
||||||
|
CS->>DB: INSERT Message
|
||||||
|
CS->>CS: Encrypt plaintext for SignalR transport
|
||||||
|
|
||||||
|
par Fan-out
|
||||||
|
CS->>SRB: SendMessageToChannelAsync (encrypted for SignalR)
|
||||||
|
and
|
||||||
|
CS->>IRCB: SendMessageToChannelAsync (decrypt → PRIVMSG)
|
||||||
|
IRCB->>IRCB: Skip sender (IRC echo suppression)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| PRIVMSG handler | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 437-469 |
|
||||||
|
| Channel name conversion | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Line 680 (`IrcToEchoHubChannel`) |
|
||||||
|
| ChatService (shared path) | `src/EchoHub.Server/Services/ChatService.cs` | Lines 145-241 |
|
||||||
|
| IRC echo suppression | `src/EchoHub.Server.Irc/IrcBroadcaster.cs` | Lines 25-26 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Receiving a Message (TUI Client)
|
||||||
|
|
||||||
|
When a message arrives via SignalR, the client decrypts it, adds it to the chat
|
||||||
|
view, and optionally plays a notification sound for @mentions.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant SRB as SignalRBroadcaster
|
||||||
|
participant EHC as EchoHubConnection
|
||||||
|
participant AO as AppOrchestrator
|
||||||
|
participant MM as ChatMessageManager
|
||||||
|
participant UI as MainWindow
|
||||||
|
|
||||||
|
SRB->>EHC: ReceiveMessage(MessageDto)
|
||||||
|
EHC->>EHC: Decrypt(message.Content)
|
||||||
|
EHC-->>AO: OnMessageReceived(decrypted dto)
|
||||||
|
AO->>AO: InvokeUI (thread-safe)
|
||||||
|
AO->>MM: AddMessage(message)
|
||||||
|
MM->>UI: Render in chat ListView
|
||||||
|
alt Message contains @username
|
||||||
|
AO->>AO: PlayAsync() notification sound
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| SignalR handler | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 64-71 |
|
||||||
|
| Orchestrator receive | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 372-383 |
|
||||||
|
| @mention detection | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 378-382 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Command Execution
|
||||||
|
|
||||||
|
Slash commands (`/status`, `/nick`, `/kick`, etc.) are parsed client-side and
|
||||||
|
dispatched to appropriate handlers, which call REST APIs or SignalR methods.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as MainWindow
|
||||||
|
participant AO as AppOrchestrator
|
||||||
|
participant CMD as CommandHandler
|
||||||
|
participant API as ApiClient
|
||||||
|
participant EHC as EchoHubConnection
|
||||||
|
participant Server as Server (API/Hub)
|
||||||
|
|
||||||
|
UI->>AO: OnMessageSubmitted(channel, "/kick baduser")
|
||||||
|
AO->>CMD: IsCommand("/kick baduser")? → true
|
||||||
|
AO->>CMD: HandleAsync("/kick baduser")
|
||||||
|
CMD->>CMD: Parse → command="kick", args="baduser"
|
||||||
|
|
||||||
|
alt API command (kick, ban, mute, nick, etc.)
|
||||||
|
CMD-->>AO: Fire OnKickRequested("baduser")
|
||||||
|
AO->>API: KickUserAsync("baduser")
|
||||||
|
API->>Server: POST /api/moderation/kick/baduser
|
||||||
|
else Hub command (status, join, leave, etc.)
|
||||||
|
CMD-->>AO: Fire OnSetStatus / OnJoinChannel / etc.
|
||||||
|
AO->>EHC: UpdateStatusAsync() / JoinChannelAsync() / etc.
|
||||||
|
EHC->>Server: SignalR Invoke
|
||||||
|
else Local command (theme, help, quit)
|
||||||
|
CMD-->>AO: Fire OnThemeChanged / etc.
|
||||||
|
AO->>UI: Apply locally (no server call)
|
||||||
|
end
|
||||||
|
|
||||||
|
AO->>UI: AddSystemMessage(result)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available commands:**
|
||||||
|
|
||||||
|
| Command | Type | Handler |
|
||||||
|
|---------|------|---------|
|
||||||
|
| `/status <status> [message]` | Hub | `UpdateStatusAsync` |
|
||||||
|
| `/nick <name>` | API | `UpdateProfileAsync` |
|
||||||
|
| `/color <hex>` | API | `UpdateProfileAsync` |
|
||||||
|
| `/join <channel>` | Hub | `JoinChannelAsync` |
|
||||||
|
| `/leave` | Hub | `LeaveChannelAsync` |
|
||||||
|
| `/topic <text>` | API | `UpdateChannelTopicAsync` |
|
||||||
|
| `/kick <user>` | API | `POST /api/moderation/kick/{user}` |
|
||||||
|
| `/ban <user>` | API | `POST /api/moderation/ban/{user}` |
|
||||||
|
| `/unban <user>` | API | `POST /api/moderation/unban/{user}` |
|
||||||
|
| `/mute <user> [mins]` | API | `POST /api/moderation/mute/{user}` |
|
||||||
|
| `/unmute <user>` | API | `POST /api/moderation/unmute/{user}` |
|
||||||
|
| `/role <user> <role>` | API | `PUT /api/moderation/role/{user}` |
|
||||||
|
| `/nuke` | API | `DELETE /api/channels/{channel}/messages` |
|
||||||
|
| `/send <file>` | API | `POST /api/channels/{channel}/upload` |
|
||||||
|
| `/profile [user]` | Local | Show profile dialog |
|
||||||
|
| `/avatar` | API | `POST /api/users/avatar` |
|
||||||
|
| `/theme <name>` | Local | `ThemeManager.SetTheme()` |
|
||||||
|
| `/servers` | API | `GET /api/serverdir/servers` |
|
||||||
|
| `/users` | Local | Show userlist |
|
||||||
|
| `/help` | Local | Show help text |
|
||||||
|
| `/quit` | Local | Exit application |
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| Command detection | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 639-655 |
|
||||||
|
| Command dispatch | `src/EchoHub.Client/Commands/CommandHandler.cs` | Lines 34-69 (`HandleAsync`) |
|
||||||
|
| Command handlers wired | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 97-117 |
|
||||||
|
| Individual handlers | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 122-350 |
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Moderation
|
||||||
|
|
||||||
|
## Kick / Ban / Mute
|
||||||
|
|
||||||
|
Moderators and admins can kick, ban, or mute users via REST API or slash commands.
|
||||||
|
These actions force-disconnect the target and broadcast the event.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Mod as Moderator
|
||||||
|
participant MC as ModerationController
|
||||||
|
participant CS as ChatService
|
||||||
|
participant PT as PresenceTracker
|
||||||
|
participant DB as SQLite
|
||||||
|
participant SRB as SignalRBroadcaster
|
||||||
|
participant IRCB as IrcBroadcaster
|
||||||
|
|
||||||
|
Mod->>MC: POST /api/moderation/kick/{user}
|
||||||
|
|
||||||
|
alt Kick
|
||||||
|
MC->>CS: Get user's channels
|
||||||
|
MC->>CS: BroadcastToAllAsync(SendUserKickedAsync)
|
||||||
|
MC->>CS: ForceDisconnectAndCleanupAsync(user)
|
||||||
|
else Ban
|
||||||
|
MC->>DB: Set user.IsBanned = true
|
||||||
|
MC->>CS: BroadcastToAllAsync(SendUserBannedAsync)
|
||||||
|
MC->>CS: ForceDisconnectAndCleanupAsync(user)
|
||||||
|
else Mute
|
||||||
|
MC->>DB: Set user.IsMuted = true, MutedUntil = now + duration
|
||||||
|
Note over DB: MuteExpirationService auto-unmutes when timer expires
|
||||||
|
end
|
||||||
|
|
||||||
|
CS->>PT: ForceRemoveUser(username)
|
||||||
|
PT->>PT: Remove from all connections + channels
|
||||||
|
CS->>SRB: ForceDisconnectUserAsync(connectionIds, reason)
|
||||||
|
CS->>IRCB: ForceDisconnectUserAsync(connectionIds, reason)
|
||||||
|
CS->>DB: Set Status=Invisible, LastSeenAt=now
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code references:**
|
||||||
|
|
||||||
|
| Step | File | Location |
|
||||||
|
|------|------|----------|
|
||||||
|
| Kick endpoint | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 62-87 |
|
||||||
|
| Ban endpoint | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 89-112 |
|
||||||
|
| Mute endpoint | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 130-151 |
|
||||||
|
| Force disconnect | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 232-256 |
|
||||||
|
| Mute expiration | `src/EchoHub.Server/Services/MuteExpirationService.cs` | Lines 22-62 |
|
||||||
|
| Mute enforcement | `src/EchoHub.Server/Services/ChatService.cs` | Lines 177-190 |
|
||||||
|
| Presence force remove | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 161-178 |
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
- name: Authentication
|
||||||
|
href: authentication.md
|
||||||
|
- name: Connection
|
||||||
|
href: connection.md
|
||||||
|
- name: Messaging
|
||||||
|
href: messaging.md
|
||||||
|
- name: Channels
|
||||||
|
href: channels.md
|
||||||
|
- name: Moderation
|
||||||
|
href: moderation.md
|
||||||
|
- name: Media & Services
|
||||||
|
href: media.md
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
- name: Articles
|
- name: Articles
|
||||||
href: articles/
|
href: articles/
|
||||||
homepage: articles/getting-started.md
|
homepage: articles/getting-started.md
|
||||||
|
- name: Flows
|
||||||
|
href: flows/
|
||||||
|
homepage: flows/flows.md
|
||||||
- name: Changelog
|
- name: Changelog
|
||||||
href: changelog/
|
href: changelog/
|
||||||
homepage: changelog/index.md
|
homepage: changelog/index.md
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
- [ ] fix the chat trailing; when user scrolls up, and somebody sends a message – the chat instantly "teleports" to the very bottom
|
||||||
|
- [x] disable the autorun of files (maybe keep for mp4? gotta do some sec research on it)
|
||||||
|
- [x] when user creates a new channel, he gets moved to that channel; but the userlist does not refresh the state on that – it refreshes when user re-enters the channel again
|
||||||
|
- [ ] password protected rooms
|
||||||
|
- [ ] better audio lib, current one (NetCoreAudio) does not support seek or other audio actions
|
||||||
|
- [ ] Use options pattern for both client & server
|
||||||
|
- ref: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-10.0
|
||||||
|
- [x] The vertical line of embeds should be the same colour as theme-color meta tag of the source
|
||||||
|
- [x] change text wrapping to honor the offset of the user. (Preferably wrap whole words if they fit in on line. Basically how the CSS "text-wrap-mode: wrap;" works)
|
||||||
|
- [x] make usernames in messages, mentions and in the user list clickable to open the user profile
|
||||||
|
- [ ] Add tags support for public servers & add filters on echohubspace for those tags in the server "browser"
|
||||||
|
- [ ] when trying to /join a channel that doesn't exist a client side-ghost channel gets created that does not work
|
||||||
|
- [x] unmute timer does not seem to work
|
||||||
|
- [ ] add IRC network like support
|
||||||
|
- that means basically multiple servers linked, so users can chat cross-server in this network
|
||||||
|
- [ ] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch
|
||||||
|
- [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down)
|
||||||
|
- [ ] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc
|
||||||
|
- [ ] Actually smart data management – cache messages, lazy load messages on scroll (currently hardcoded 100msgs fetched + new ones)
|
||||||
|
- [x] Another thing would be stateful userlist – basically fetch once and listen for userlist updates
|
||||||
|
- [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update
|
||||||
|
- [x] space between mod|admin "icon" and username
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<configuration>
|
|
||||||
<packageSources>
|
|
||||||
<clear />
|
|
||||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
|
||||||
</packageSources>
|
|
||||||
<packageSourceMapping>
|
|
||||||
<packageSource key="nuget.org">
|
|
||||||
<package pattern="*" />
|
|
||||||
</packageSource>
|
|
||||||
</packageSourceMapping>
|
|
||||||
</configuration>
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
|
||||||
|
<metadata>
|
||||||
|
<id>echohub</id>
|
||||||
|
<version>__VERSION__</version>
|
||||||
|
<title>EchoHub</title>
|
||||||
|
<authors>HueByte</authors>
|
||||||
|
<owners>HueByte</owners>
|
||||||
|
<requireLicenseAcceptance>false</requireLicenseAcceptance>
|
||||||
|
<licenseUrl>https://github.com/HueByteByte/EchoHub/blob/master/LICENSE</licenseUrl>
|
||||||
|
<projectUrl>https://github.com/HueByteByte/EchoHub</projectUrl>
|
||||||
|
<projectSourceUrl>https://github.com/HueByteByte/EchoHub</projectSourceUrl>
|
||||||
|
<docsUrl>https://HueBytebyte.github.io/EchoHub</docsUrl>
|
||||||
|
<bugTrackerUrl>https://github.com/HueByteByte/EchoHub/issues</bugTrackerUrl>
|
||||||
|
<packageSourceUrl>https://github.com/HueByteByte/EchoHub/tree/master/packaging/choco</packageSourceUrl>
|
||||||
|
<iconUrl>https://raw.githubusercontent.com/HueByteByte/EchoHub/master/assets/HueByte_icon.png</iconUrl>
|
||||||
|
<description>EchoHub is a decentralized IRC-style chat application with a terminal user interface (TUI). Connect to any EchoHub server, join channels, and chat — all from your terminal. Features include SignalR real-time messaging, file sharing, custom themes, and IRC gateway compatibility.</description>
|
||||||
|
<summary>Decentralized terminal chat client with IRC gateway support</summary>
|
||||||
|
<tags>chat irc decentralized tui terminal signalr echohub</tags>
|
||||||
|
<releaseNotes>https://HueBytebyte.github.io/EchoHub/changelog/v__VERSION__.html</releaseNotes>
|
||||||
|
<copyright>Copyright (c) 2026 HueByte</copyright>
|
||||||
|
</metadata>
|
||||||
|
<files>
|
||||||
|
<file src="tools\**" target="tools" />
|
||||||
|
</files>
|
||||||
|
</package>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hue
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
VERIFICATION
|
||||||
|
|
||||||
|
To verify the package contents:
|
||||||
|
|
||||||
|
1. Download the official release from:
|
||||||
|
https://github.com/HueByte/EchoHub/releases
|
||||||
|
|
||||||
|
2. Download: EchoHub-Client-win-x64.zip
|
||||||
|
|
||||||
|
3. Calculate the SHA256 checksum:
|
||||||
|
- PowerShell: Get-FileHash EchoHub-Client-win-x64.zip -Algorithm SHA256
|
||||||
|
- Linux/macOS: sha256sum EchoHub-Client-win-x64.zip
|
||||||
|
|
||||||
|
4. Compare with the checksum in chocolateyInstall.ps1 (checksum64 value).
|
||||||
|
|
||||||
|
LICENSE: MIT License - see LICENSE.txt in this directory or
|
||||||
|
https://github.com/HueByte/EchoHub/blob/master/LICENSE
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
$version = $env:chocolateyPackageVersion
|
||||||
|
|
||||||
|
$packageArgs = @{
|
||||||
|
packageName = $env:chocolateyPackageName
|
||||||
|
unzipLocation = $toolsDir
|
||||||
|
url64bit = "https://github.com/HueByte/EchoHub/releases/download/v$version/EchoHub-Client-win-x64.zip"
|
||||||
|
checksum64 = '__CHECKSUM64__'
|
||||||
|
checksumType64 = 'sha256'
|
||||||
|
}
|
||||||
|
|
||||||
|
Install-ChocolateyZipPackage @packageArgs
|
||||||
|
|
||||||
|
# Create a shim so 'echohub' is available on PATH
|
||||||
|
$exePath = Join-Path $toolsDir 'client-win-x64' 'EchoHub.Client.exe'
|
||||||
|
Install-BinFile -Name 'echohub' -Path $exePath
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
Uninstall-BinFile -Name 'echohub'
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# EchoHub Client Installer
|
||||||
|
# Usage: curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/install.sh | sh
|
||||||
|
#
|
||||||
|
# Options (pass as arguments or environment variables):
|
||||||
|
# --version X.Y.Z Install a specific version (default: latest)
|
||||||
|
# --install-dir DIR Install to a custom directory
|
||||||
|
# --help Show this help message
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
REPO="HueByte/EchoHub"
|
||||||
|
BINARY_NAME="echohub"
|
||||||
|
INSTALL_DIR=""
|
||||||
|
VERSION=""
|
||||||
|
|
||||||
|
# ── Argument parsing ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--version)
|
||||||
|
VERSION="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--install-dir)
|
||||||
|
INSTALL_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--help)
|
||||||
|
sed -n '2,8p' "$0" 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh"
|
||||||
|
echo " curl ... | sh -s -- --version 0.2.8"
|
||||||
|
echo " curl ... | sh -s -- --install-dir /opt/echohub"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Platform detection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
detect_os() {
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Linux*) echo "linux" ;;
|
||||||
|
Darwin*) echo "osx" ;;
|
||||||
|
*)
|
||||||
|
echo "Error: Unsupported operating system: $(uname -s)" >&2
|
||||||
|
echo "EchoHub supports Linux and macOS. For Windows, use: choco install echohub" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_arch() {
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64|amd64) echo "x64" ;;
|
||||||
|
aarch64|arm64) echo "arm64" ;;
|
||||||
|
*)
|
||||||
|
echo "Error: Unsupported architecture: $(uname -m)" >&2
|
||||||
|
echo "EchoHub supports x64 and arm64." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Version resolution ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
echo "$VERSION"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fetch latest release tag from GitHub API
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
tag=$(curl -sSf "https://api.github.com/repos/$REPO/releases/latest" \
|
||||||
|
| grep '"tag_name"' | head -1 | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\?\([^"]*\)".*/\1/')
|
||||||
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
|
tag=$(wget -qO- "https://api.github.com/repos/$REPO/releases/latest" \
|
||||||
|
| grep '"tag_name"' | head -1 | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\?\([^"]*\)".*/\1/')
|
||||||
|
else
|
||||||
|
echo "Error: curl or wget is required to detect the latest version." >&2
|
||||||
|
echo "Install curl/wget or specify a version with --version X.Y.Z" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$tag" ]; then
|
||||||
|
echo "Error: Could not determine latest version from GitHub." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Install directory resolution ──────────────────────────────────────────
|
||||||
|
|
||||||
|
resolve_install_dir() {
|
||||||
|
if [ -n "$INSTALL_DIR" ]; then
|
||||||
|
echo "$INSTALL_DIR"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prefer /usr/local/bin if writable, otherwise ~/.local/bin
|
||||||
|
if [ -w "/usr/local/bin" ]; then
|
||||||
|
echo "/usr/local/bin"
|
||||||
|
else
|
||||||
|
local_bin="$HOME/.local/bin"
|
||||||
|
mkdir -p "$local_bin"
|
||||||
|
echo "$local_bin"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Download helper ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
download() {
|
||||||
|
url="$1"
|
||||||
|
output="$2"
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
curl -sSfL "$url" -o "$output"
|
||||||
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
|
wget -qO "$output" "$url"
|
||||||
|
else
|
||||||
|
echo "Error: curl or wget is required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── PATH setup ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
add_to_path() {
|
||||||
|
dir="$1"
|
||||||
|
export_line="export PATH=\"${dir}:\$PATH\" # Added by EchoHub"
|
||||||
|
|
||||||
|
added=0
|
||||||
|
for profile in "$HOME/.profile" "$HOME/.bashrc" "$HOME/.zshrc"; do
|
||||||
|
if [ -f "$profile" ]; then
|
||||||
|
if grep -q "$dir" "$profile" 2>/dev/null; then
|
||||||
|
continue # Already present
|
||||||
|
fi
|
||||||
|
printf '\n%s\n' "$export_line" >> "$profile"
|
||||||
|
echo " Added to PATH in $(basename "$profile")"
|
||||||
|
added=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# If no profile existed, create .profile
|
||||||
|
if [ "$added" -eq 0 ]; then
|
||||||
|
printf '\n%s\n' "$export_line" >> "$HOME/.profile"
|
||||||
|
echo " Added to PATH in .profile"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
main() {
|
||||||
|
os=$(detect_os)
|
||||||
|
arch=$(detect_arch)
|
||||||
|
version=$(resolve_version)
|
||||||
|
install_dir=$(resolve_install_dir)
|
||||||
|
|
||||||
|
artifact="EchoHub-Client-${os}-${arch}.zip"
|
||||||
|
url="https://github.com/$REPO/releases/download/v${version}/${artifact}"
|
||||||
|
|
||||||
|
echo "EchoHub Installer"
|
||||||
|
echo " Version: v${version}"
|
||||||
|
echo " Platform: ${os}-${arch}"
|
||||||
|
echo " Install to: ${install_dir}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create temp directory with cleanup trap
|
||||||
|
tmpdir=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmpdir"' EXIT
|
||||||
|
|
||||||
|
echo "Downloading ${artifact}..."
|
||||||
|
download "$url" "$tmpdir/echohub.zip"
|
||||||
|
|
||||||
|
echo "Extracting..."
|
||||||
|
if command -v unzip >/dev/null 2>&1; then
|
||||||
|
unzip -qo "$tmpdir/echohub.zip" -d "$tmpdir/extract"
|
||||||
|
else
|
||||||
|
echo "Error: unzip is required to extract the archive." >&2
|
||||||
|
echo "Install it with: apt install unzip / brew install unzip" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The ZIP contains a client-{os}-{arch}/ subdirectory
|
||||||
|
src_dir="$tmpdir/extract/client-${os}-${arch}"
|
||||||
|
if [ ! -d "$src_dir" ]; then
|
||||||
|
# Fallback: look for any directory containing the binary
|
||||||
|
src_dir=$(find "$tmpdir/extract" -name "EchoHub.Client" -type f -printf '%h' -quit 2>/dev/null || true)
|
||||||
|
if [ -z "$src_dir" ]; then
|
||||||
|
echo "Error: Could not find EchoHub.Client binary in the archive." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install the binary
|
||||||
|
mkdir -p "$install_dir"
|
||||||
|
cp "$src_dir/EchoHub.Client" "$install_dir/$BINARY_NAME"
|
||||||
|
chmod +x "$install_dir/$BINARY_NAME"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Ensure install directory is on PATH
|
||||||
|
if ! command -v "$BINARY_NAME" >/dev/null 2>&1; then
|
||||||
|
add_to_path "$install_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installed successfully! Run 'echohub' to start."
|
||||||
|
echo ""
|
||||||
|
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$install_dir"; then
|
||||||
|
echo "NOTE: Restart your shell or run the following to use echohub now:"
|
||||||
|
echo ""
|
||||||
|
echo " export PATH=\"${install_dir}:\$PATH\""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.6</Version>
|
<Version>0.2.8</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private readonly AudioPlaybackService _audioPlayback = new();
|
private readonly AudioPlaybackService _audioPlayback = new();
|
||||||
private readonly UpdateChecker _updateService;
|
private readonly UpdateChecker _updateService;
|
||||||
private readonly ConnectionManager _conn = new();
|
private readonly ConnectionManager _conn = new();
|
||||||
|
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Lock _channelUsersLock = new();
|
||||||
|
|
||||||
private ClientConfig _config;
|
private ClientConfig _config;
|
||||||
private readonly UserSession _session = new();
|
private readonly UserSession _session = new();
|
||||||
@@ -85,6 +87,10 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
||||||
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
||||||
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||||
|
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
||||||
|
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
||||||
|
_mainWindow.OnUserProfileRequested += HandleViewProfile;
|
||||||
|
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||||
@@ -379,17 +385,48 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
_conn.UserJoined += (channelName, username) =>
|
_conn.UserJoined += (channelName, username, presence) =>
|
||||||
{
|
{
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} joined the channel"));
|
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} joined the channel"));
|
||||||
if (channelName == _mainWindow.CurrentChannel)
|
|
||||||
|
List<UserPresenceDto>? snapshot = null;
|
||||||
|
lock (_channelUsersLock)
|
||||||
|
{
|
||||||
|
if (presence is not null && _channelUsers.TryGetValue(channelName, out var users))
|
||||||
|
{
|
||||||
|
if (!users.Any(u => u.Username.Equals(presence.Username, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
users.Add(presence);
|
||||||
|
|
||||||
|
if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
snapshot = [.. users];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot is not null)
|
||||||
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
|
||||||
|
else if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
|
||||||
FetchAndUpdateOnlineUsers();
|
FetchAndUpdateOnlineUsers();
|
||||||
};
|
};
|
||||||
|
|
||||||
_conn.UserLeft += (channelName, username) =>
|
_conn.UserLeft += (channelName, username) =>
|
||||||
{
|
{
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} left the channel"));
|
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} left the channel"));
|
||||||
if (channelName == _mainWindow.CurrentChannel)
|
|
||||||
|
List<UserPresenceDto>? snapshot = null;
|
||||||
|
lock (_channelUsersLock)
|
||||||
|
{
|
||||||
|
if (_channelUsers.TryGetValue(channelName, out var users))
|
||||||
|
{
|
||||||
|
users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
snapshot = [.. users];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot is not null)
|
||||||
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
|
||||||
|
else if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
|
||||||
FetchAndUpdateOnlineUsers();
|
FetchAndUpdateOnlineUsers();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -405,18 +442,75 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
foreach (var channelName in _mainWindow.GetChannelNames())
|
foreach (var channelName in _mainWindow.GetChannelNames())
|
||||||
_messageManager.AddStatusMessage(channelName, displayName, statusText);
|
_messageManager.AddStatusMessage(channelName, displayName, statusText);
|
||||||
});
|
});
|
||||||
FetchAndUpdateOnlineUsers();
|
|
||||||
|
// Update presence in all cached channel lists
|
||||||
|
List<UserPresenceDto>? snapshot = null;
|
||||||
|
lock (_channelUsersLock)
|
||||||
|
{
|
||||||
|
foreach (var (channel, users) in _channelUsers)
|
||||||
|
{
|
||||||
|
var idx = users.FindIndex(u => u.Username.Equals(presence.Username, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (idx >= 0)
|
||||||
|
{
|
||||||
|
if (presence.Status == UserStatus.Invisible)
|
||||||
|
users.RemoveAt(idx);
|
||||||
|
else
|
||||||
|
users[idx] = presence;
|
||||||
|
}
|
||||||
|
else if (presence.Status != UserStatus.Invisible)
|
||||||
|
{
|
||||||
|
// User came back from invisible — re-add them
|
||||||
|
users.Add(presence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentChannel = _mainWindow.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(currentChannel) && _channelUsers.TryGetValue(currentChannel, out var currentUsers))
|
||||||
|
snapshot = [.. currentUsers];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot is not null)
|
||||||
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
|
||||||
};
|
};
|
||||||
|
|
||||||
_conn.UserKicked += (channelName, username, reason) =>
|
_conn.UserKicked += (channelName, username, reason) =>
|
||||||
{
|
{
|
||||||
var reasonText = reason is not null ? $" ({reason})" : "";
|
var reasonText = reason is not null ? $" ({reason})" : "";
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} was kicked{reasonText}"));
|
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} was kicked{reasonText}"));
|
||||||
|
|
||||||
|
List<UserPresenceDto>? snapshot = null;
|
||||||
|
lock (_channelUsersLock)
|
||||||
|
{
|
||||||
|
if (_channelUsers.TryGetValue(channelName, out var users))
|
||||||
|
{
|
||||||
|
users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
snapshot = [.. users];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot is not null)
|
||||||
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
|
||||||
};
|
};
|
||||||
|
|
||||||
_conn.UserBanned += (username, reason) =>
|
_conn.UserBanned += (username, reason) =>
|
||||||
{
|
{
|
||||||
var reasonText = reason is not null ? $" ({reason})" : "";
|
var reasonText = reason is not null ? $" ({reason})" : "";
|
||||||
|
|
||||||
|
List<UserPresenceDto>? snapshot = null;
|
||||||
|
lock (_channelUsersLock)
|
||||||
|
{
|
||||||
|
// Remove banned user from all cached channel lists
|
||||||
|
foreach (var (channel, users) in _channelUsers)
|
||||||
|
{
|
||||||
|
users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentChannel = _mainWindow.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(currentChannel) && _channelUsers.TryGetValue(currentChannel, out var currentUsers))
|
||||||
|
snapshot = [.. currentUsers];
|
||||||
|
}
|
||||||
|
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
if (!username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase))
|
if (!username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -424,6 +518,9 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
var channel = _mainWindow.CurrentChannel;
|
var channel = _mainWindow.CurrentChannel;
|
||||||
if (!string.IsNullOrEmpty(channel))
|
if (!string.IsNullOrEmpty(channel))
|
||||||
_messageManager.AddSystemMessage(channel, $"{username} was banned{reasonText}");
|
_messageManager.AddSystemMessage(channel, $"{username} was banned{reasonText}");
|
||||||
|
|
||||||
|
if (snapshot is not null)
|
||||||
|
_mainWindow.UpdateOnlineUsers(snapshot);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -467,6 +564,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
_conn.Reconnected += () =>
|
_conn.Reconnected += () =>
|
||||||
{
|
{
|
||||||
|
lock (_channelUsersLock) _channelUsers.Clear();
|
||||||
RunAsync(
|
RunAsync(
|
||||||
async () => await _conn.RejoinChannelsAsync(),
|
async () => await _conn.RejoinChannelsAsync(),
|
||||||
"Failed to rejoin channels after reconnect");
|
"Failed to rejoin channels after reconnect");
|
||||||
@@ -524,9 +622,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
if (result.DefaultHistory.Count > 0)
|
if (result.DefaultHistory.Count > 0)
|
||||||
_messageManager.LoadHistory(HubConstants.DefaultChannel, result.DefaultHistory);
|
_messageManager.LoadHistory(HubConstants.DefaultChannel, result.DefaultHistory);
|
||||||
_mainWindow.FocusInput();
|
_mainWindow.FocusInput();
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
});
|
});
|
||||||
|
|
||||||
FetchAndUpdateOnlineUsers();
|
|
||||||
SaveServerToConfig(dialogResult);
|
SaveServerToConfig(dialogResult);
|
||||||
}, "Connection failed", "Connect");
|
}, "Connection failed", "Connect");
|
||||||
}
|
}
|
||||||
@@ -534,6 +631,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private void HandleDisconnect()
|
private void HandleDisconnect()
|
||||||
{
|
{
|
||||||
Log.Information("Disconnecting from server");
|
Log.Information("Disconnecting from server");
|
||||||
|
lock (_channelUsersLock) _channelUsers.Clear();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -622,6 +720,19 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}, "Failed to join channel");
|
}, "Failed to join channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleChannelJoinFromMessage(string channelName)
|
||||||
|
{
|
||||||
|
if (!_conn.IsConnected) return;
|
||||||
|
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.EnsureChannelInList(channelName);
|
||||||
|
_mainWindow.SwitchToChannel(channelName);
|
||||||
|
});
|
||||||
|
|
||||||
|
HandleChannelSelected(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleProfileRequested()
|
private void HandleProfileRequested()
|
||||||
{
|
{
|
||||||
HandleViewProfile(null);
|
HandleViewProfile(null);
|
||||||
@@ -825,6 +936,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
if (history.Count > 0)
|
if (history.Count > 0)
|
||||||
_messageManager.LoadHistory(channel.Name, history);
|
_messageManager.LoadHistory(channel.Name, history);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
}, "Failed to create channel");
|
}, "Failed to create channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,6 +993,16 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}, "Failed to play audio");
|
}, "Failed to play audio");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// File extensions considered safe to open with the system default application.
|
||||||
|
/// Everything else is downloaded only — never auto-opened via UseShellExecute.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly HashSet<string> SafeOpenExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".mp4", ".webm", ".mkv", ".avi", ".mov", // video
|
||||||
|
".pdf", ".txt", ".csv", ".json", ".xml", // documents
|
||||||
|
};
|
||||||
|
|
||||||
private void HandleFileDownloadRequested(string attachmentUrl, string fileName)
|
private void HandleFileDownloadRequested(string attachmentUrl, string fileName)
|
||||||
{
|
{
|
||||||
if (!_conn.IsAuthenticated) return;
|
if (!_conn.IsAuthenticated) return;
|
||||||
@@ -889,19 +1012,58 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||||
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||||
|
|
||||||
try
|
var ext = Path.GetExtension(fileName);
|
||||||
|
if (SafeOpenExtensions.Contains(ext))
|
||||||
{
|
{
|
||||||
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
|
try
|
||||||
System.Diagnostics.Process.Start(psi);
|
{
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
|
||||||
|
System.Diagnostics.Process.Start(psi);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
else
|
||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
|
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
||||||
}
|
}
|
||||||
}, "Failed to download file");
|
}, "Failed to download file");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleCheckForUpdatesRequested()
|
||||||
|
{
|
||||||
|
RunAsync(_updateService.CheckNowAsync, "Failed to check for updates");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleRollbackRequested()
|
||||||
|
{
|
||||||
|
if (!UpdateBackupService.BackupExists())
|
||||||
|
{
|
||||||
|
MessageBox.ErrorQuery(_app, "No Backup", "No backup is available to restore.", "OK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = UpdateBackupService.GetBackupInfo();
|
||||||
|
var confirm = MessageBox.Query(_app, "Rollback Update",
|
||||||
|
$"Restore to version {info?.Version ?? "unknown"}?\n\nThe app will restart.", "Restore", "Cancel");
|
||||||
|
|
||||||
|
if (confirm != 0) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UpdateBackupService.RestoreBackup();
|
||||||
|
// RestoreBackup calls Environment.Exit(0)
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Rollback failed");
|
||||||
|
MessageBox.ErrorQuery(_app, "Rollback Failed", $"Could not restore: {ex.Message}", "OK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Private Helpers ────────────────────────────────────────────────────
|
// ── Private Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void FetchAndUpdateOnlineUsers()
|
private void FetchAndUpdateOnlineUsers()
|
||||||
@@ -914,6 +1076,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var users = await _conn.GetOnlineUsersAsync(channel);
|
var users = await _conn.GetOnlineUsersAsync(channel);
|
||||||
|
lock (_channelUsersLock) _channelUsers[channel] = users;
|
||||||
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -5,15 +5,14 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AlwaysUpToDate" Version="2.0.1" />
|
<PackageReference Include="AlwaysUpToDate" Version="2.0.2.20250223" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
||||||
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
|
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
|
||||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<!-- Using local fork until transparent color support is merged upstream (gui-cs/Terminal.Gui#4234) -->
|
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5039" />
|
||||||
<ProjectReference Include="..\Terminal.Gui\Terminal.Gui\Terminal.Gui.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,12 +1,58 @@
|
|||||||
using EchoHub.Client;
|
using EchoHub.Client;
|
||||||
using EchoHub.Client.Config;
|
using EchoHub.Client.Config;
|
||||||
|
using EchoHub.Client.Services;
|
||||||
using EchoHub.Client.Themes;
|
using EchoHub.Client.Themes;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
using Terminal.Gui.Drawing;
|
|
||||||
|
|
||||||
|
// == CLI rollback: works without TUI, before anything else ================
|
||||||
|
if (args.Contains("--rollback"))
|
||||||
|
{
|
||||||
|
if (UpdateBackupService.BackupExists())
|
||||||
|
{
|
||||||
|
var info = UpdateBackupService.GetBackupInfo();
|
||||||
|
Console.WriteLine($"Rolling back to version {info?.Version ?? "unknown"}...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UpdateBackupService.RestoreBackup();
|
||||||
|
// RestoreBackup calls Environment.Exit(0)
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Rollback failed: {ex.Message}");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("No backup available to restore.");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Unix permission self-check (defense-in-depth after auto-update) ==
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
var exePath = Environment.ProcessPath;
|
||||||
|
if (!string.IsNullOrEmpty(exePath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mode = File.GetUnixFileMode(exePath);
|
||||||
|
if ((mode & UnixFileMode.UserExecute) == 0)
|
||||||
|
{
|
||||||
|
File.SetUnixFileMode(exePath, mode | UnixFileMode.UserExecute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort; if we're running, we already have execute permission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Normal startup ==
|
||||||
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||||
if (!File.Exists(appSettingsPath))
|
if (!File.Exists(appSettingsPath))
|
||||||
{
|
{
|
||||||
@@ -31,6 +77,41 @@ Log.Logger = new LoggerConfiguration()
|
|||||||
|
|
||||||
Log.Information("EchoHub client starting");
|
Log.Information("EchoHub client starting");
|
||||||
|
|
||||||
|
// == Ensure echohub is on PATH for convenient terminal access ============
|
||||||
|
PathSetup.EnsureOnPath();
|
||||||
|
|
||||||
|
// == Post-update detection: stale backup cleanup or flag for rollback menu ================
|
||||||
|
if (UpdateBackupService.BackupExists())
|
||||||
|
{
|
||||||
|
var backupInfo = UpdateBackupService.GetBackupInfo();
|
||||||
|
if (backupInfo is not null && DateTimeOffset.UtcNow - backupInfo.CreatedAt > TimeSpan.FromDays(7))
|
||||||
|
{
|
||||||
|
Log.Information("Deleting stale update backup from {Date}", backupInfo.CreatedAt);
|
||||||
|
UpdateBackupService.DeleteBackup();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.Information("Post-update: backup of v{OldVersion} available for rollback",
|
||||||
|
backupInfo?.Version ?? "unknown");
|
||||||
|
UpdateBackupService.IsPostUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Windows: clean up .old executable left by rollback restore ===========
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
var currentExe = Environment.ProcessPath;
|
||||||
|
if (!string.IsNullOrEmpty(currentExe))
|
||||||
|
{
|
||||||
|
var oldExe = currentExe + ".old";
|
||||||
|
if (File.Exists(oldExe))
|
||||||
|
{
|
||||||
|
try { File.Delete(oldExe); }
|
||||||
|
catch { /* locked or permission issue — will be cleaned next launch */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var config = ConfigManager.Load();
|
var config = ConfigManager.Load();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
|
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
|
||||||
{
|
{
|
||||||
var request = new RegisterRequest(username, password, displayName);
|
var request = new RegisterRequest(username, password, displayName);
|
||||||
var response = await _http.PostAsJsonAsync("/api/auth/register", request);
|
using var response = await _http.PostAsJsonAsync("/api/auth/register", request);
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
@@ -45,7 +45,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<LoginResponse> LoginAsync(string username, string password)
|
public async Task<LoginResponse> LoginAsync(string username, string password)
|
||||||
{
|
{
|
||||||
var request = new LoginRequest(username, password);
|
var request = new LoginRequest(username, password);
|
||||||
var response = await _http.PostAsJsonAsync("/api/auth/login", request);
|
using var response = await _http.PostAsJsonAsync("/api/auth/login", request);
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
@@ -61,7 +61,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
throw new InvalidOperationException("No refresh token available.");
|
throw new InvalidOperationException("No refresh token available.");
|
||||||
|
|
||||||
var request = new RefreshRequest(_refreshToken);
|
var request = new RefreshRequest(_refreshToken);
|
||||||
var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
|
using var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
@@ -73,7 +73,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<LoginResponse> LoginWithRefreshTokenAsync(string refreshToken)
|
public async Task<LoginResponse> LoginWithRefreshTokenAsync(string refreshToken)
|
||||||
{
|
{
|
||||||
var request = new RefreshRequest(refreshToken);
|
var request = new RefreshRequest(refreshToken);
|
||||||
var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
|
using var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||||
@@ -90,7 +90,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var request = new RefreshRequest(_refreshToken);
|
var request = new RefreshRequest(_refreshToken);
|
||||||
await _http.PostAsJsonAsync("/api/auth/logout", request);
|
using var response = await _http.PostAsJsonAsync("/api/auth/logout", request);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -131,7 +131,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<List<ChannelDto>> GetChannelsAsync()
|
public async Task<List<ChannelDto>> GetChannelsAsync()
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedGetAsync("/api/channels");
|
using var response = await AuthenticatedGetAsync("/api/channels");
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
var paginated = await response.Content.ReadFromJsonAsync<PaginatedResponse<ChannelDto>>();
|
var paginated = await response.Content.ReadFromJsonAsync<PaginatedResponse<ChannelDto>>();
|
||||||
return paginated?.Items ?? [];
|
return paginated?.Items ?? [];
|
||||||
@@ -146,7 +146,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<string> GetEncryptionKeyAsync()
|
public async Task<string> GetEncryptionKeyAsync()
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedGetAsync("/api/server/encryption-key");
|
using var response = await AuthenticatedGetAsync("/api/server/encryption-key");
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
var result = await response.Content.ReadFromJsonAsync<EncryptionKeyResponse>()
|
var result = await response.Content.ReadFromJsonAsync<EncryptionKeyResponse>()
|
||||||
?? throw new InvalidOperationException("Server returned empty encryption key response.");
|
?? throw new InvalidOperationException("Server returned empty encryption key response.");
|
||||||
@@ -156,7 +156,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile");
|
using var response = await AuthenticatedGetAsync($"/api/users/{Uri.EscapeDataString(username)}/profile");
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
|
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PutAsJsonAsync("/api/users/profile", request));
|
_http.PutAsJsonAsync("/api/users/profile", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||||
@@ -178,7 +178,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
||||||
content.Add(streamContent, "file", fileName);
|
content.Add(streamContent, "file", fileName);
|
||||||
|
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsync("/api/users/avatar", content));
|
_http.PostAsync("/api/users/avatar", content));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
|
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
|
||||||
@@ -194,7 +194,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
content.Add(streamContent, "file", fileName);
|
content.Add(streamContent, "file", fileName);
|
||||||
|
|
||||||
var sizeQuery = size is not null ? $"?size={size}" : "";
|
var sizeQuery = size is not null ? $"?size={size}" : "";
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
|
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
@@ -205,7 +205,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new SendUrlRequest(url);
|
var request = new SendUrlRequest(url);
|
||||||
var sizeQuery = size is not null ? $"?size={size}" : "";
|
var sizeQuery = size is not null ? $"?size={size}" : "";
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request));
|
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||||
@@ -214,7 +214,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
|
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedGetAsync(relativeUrl);
|
using var response = await AuthenticatedGetAsync(relativeUrl);
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub");
|
var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub");
|
||||||
@@ -232,7 +232,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new CreateChannelRequest(name, topic, isPublic);
|
var request = new CreateChannelRequest(name, topic, isPublic);
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync("/api/channels", request));
|
_http.PostAsJsonAsync("/api/channels", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||||
@@ -242,7 +242,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var request = new UpdateTopicRequest(topic);
|
var request = new UpdateTopicRequest(topic);
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request));
|
_http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||||
@@ -251,7 +251,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task DeleteChannelAsync(string channelName)
|
public async Task DeleteChannelAsync(string channelName)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}"));
|
_http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}"));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -261,7 +261,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task AssignRoleAsync(string username, ServerRole role)
|
public async Task AssignRoleAsync(string username, ServerRole role)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
|
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task KickUserAsync(string username, string? reason = null)
|
public async Task KickUserAsync(string username, string? reason = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
|
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -277,7 +277,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task BanUserAsync(string username, string? reason = null)
|
public async Task BanUserAsync(string username, string? reason = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
|
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -285,7 +285,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task UnbanUserAsync(string username)
|
public async Task UnbanUserAsync(string username)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
|
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
|
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
|
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -301,7 +301,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task UnmuteUserAsync(string username)
|
public async Task UnmuteUserAsync(string username)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
|
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task DeleteMessageAsync(Guid messageId)
|
public async Task DeleteMessageAsync(Guid messageId)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
|
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
public async Task NukeChannelAsync(string channelName)
|
public async Task NukeChannelAsync(string channelName)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
var response = await AuthenticatedRequestAsync(() =>
|
using var response = await AuthenticatedRequestAsync(() =>
|
||||||
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
|
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
@@ -333,6 +333,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs a GET request with automatic token refresh on 401.
|
/// Performs a GET request with automatic token refresh on 401.
|
||||||
|
/// Caller is responsible for disposing the returned response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<HttpResponseMessage> AuthenticatedGetAsync(string url)
|
private async Task<HttpResponseMessage> AuthenticatedGetAsync(string url)
|
||||||
{
|
{
|
||||||
@@ -343,7 +344,9 @@ public sealed class ApiClient : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await RefreshTokenAsync();
|
await RefreshTokenAsync();
|
||||||
response = await _http.GetAsync(url);
|
var retryResponse = await _http.GetAsync(url);
|
||||||
|
response.Dispose();
|
||||||
|
response = retryResponse;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -356,6 +359,7 @@ public sealed class ApiClient : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Performs a request with automatic token refresh on 401.
|
/// Performs a request with automatic token refresh on 401.
|
||||||
|
/// Caller is responsible for disposing the returned response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<HttpResponseMessage> AuthenticatedRequestAsync(Func<Task<HttpResponseMessage>> requestFactory)
|
private async Task<HttpResponseMessage> AuthenticatedRequestAsync(Func<Task<HttpResponseMessage>> requestFactory)
|
||||||
{
|
{
|
||||||
@@ -366,7 +370,9 @@ public sealed class ApiClient : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await RefreshTokenAsync();
|
await RefreshTokenAsync();
|
||||||
response = await requestFactory();
|
var retryResponse = await requestFactory();
|
||||||
|
response.Dispose();
|
||||||
|
response = retryResponse;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace EchoHub.Client.Services;
|
|||||||
public class AudioPlaybackService
|
public class AudioPlaybackService
|
||||||
{
|
{
|
||||||
private readonly Player _player = new();
|
private readonly Player _player = new();
|
||||||
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
public bool IsPlaying => _player.Playing;
|
public bool IsPlaying => _player.Playing;
|
||||||
public bool IsPaused => _player.Paused;
|
public bool IsPaused => _player.Paused;
|
||||||
@@ -19,6 +20,7 @@ public class AudioPlaybackService
|
|||||||
|
|
||||||
public async Task PlayAsync(string filePath)
|
public async Task PlayAsync(string filePath)
|
||||||
{
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_player.Playing)
|
if (_player.Playing)
|
||||||
@@ -30,10 +32,15 @@ public class AudioPlaybackService
|
|||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to play audio file: {Path}", filePath);
|
Log.Warning(ex, "Failed to play audio file: {Path}", filePath);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PauseAsync()
|
public async Task PauseAsync()
|
||||||
{
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_player.Playing && !_player.Paused)
|
if (_player.Playing && !_player.Paused)
|
||||||
@@ -43,10 +50,15 @@ public class AudioPlaybackService
|
|||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to pause audio playback");
|
Log.Warning(ex, "Failed to pause audio playback");
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ResumeAsync()
|
public async Task ResumeAsync()
|
||||||
{
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_player.Paused)
|
if (_player.Paused)
|
||||||
@@ -56,23 +68,33 @@ public class AudioPlaybackService
|
|||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to resume audio playback");
|
Log.Warning(ex, "Failed to resume audio playback");
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StopAsync()
|
public async Task StopAsync()
|
||||||
{
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_player.Playing)
|
if (_player.Playing || _player.Paused)
|
||||||
await _player.Stop();
|
await _player.Stop();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to stop audio playback");
|
Log.Warning(ex, "Failed to stop audio playback");
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SetVolumeAsync(byte volume)
|
public async Task SetVolumeAsync(byte volume)
|
||||||
{
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _player.SetVolume(Math.Min(volume, (byte)100));
|
await _player.SetVolume(Math.Min(volume, (byte)100));
|
||||||
@@ -81,5 +103,9 @@ public class AudioPlaybackService
|
|||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to set audio volume");
|
Log.Warning(ex, "Failed to set audio volume");
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
// ── Events (forwarded from SignalR) ───────────────────────────────────
|
// ── Events (forwarded from SignalR) ───────────────────────────────────
|
||||||
|
|
||||||
public event Action<MessageDto>? MessageReceived;
|
public event Action<MessageDto>? MessageReceived;
|
||||||
public event Action<string, string>? UserJoined;
|
public event Action<string, string, UserPresenceDto?>? UserJoined;
|
||||||
public event Action<string, string>? UserLeft;
|
public event Action<string, string>? UserLeft;
|
||||||
public event Action<UserPresenceDto>? UserStatusChanged;
|
public event Action<UserPresenceDto>? UserStatusChanged;
|
||||||
public event Action<string, string, string?>? UserKicked;
|
public event Action<string, string, string?>? UserKicked;
|
||||||
@@ -60,77 +60,83 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
_apiClient?.Dispose();
|
_apiClient?.Dispose();
|
||||||
_apiClient = new ApiClient(info.ServerUrl);
|
_apiClient = new ApiClient(info.ServerUrl);
|
||||||
|
|
||||||
onStatus("Authenticating...");
|
try
|
||||||
|
|
||||||
LoginResponse loginResponse;
|
|
||||||
|
|
||||||
if (info.SavedRefreshToken is not null)
|
|
||||||
{
|
{
|
||||||
try
|
onStatus("Authenticating...");
|
||||||
|
|
||||||
|
LoginResponse loginResponse;
|
||||||
|
|
||||||
|
if (info.SavedRefreshToken is not null)
|
||||||
{
|
{
|
||||||
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken);
|
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken);
|
||||||
Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
|
Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
|
||||||
}
|
}
|
||||||
|
else if (info.IsRegister)
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.LoginAsync(info.Username, info.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-persist rotated refresh tokens for Remember Me
|
||||||
|
_apiClient.OnTokensRefreshed += HandleTokensRefreshed;
|
||||||
|
|
||||||
|
// E2E encryption key
|
||||||
|
onStatus("Fetching encryption key...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
|
||||||
|
_encryption.SetKey(encryptionKey);
|
||||||
|
Log.Information("E2E encryption key established");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus("Authenticated, connecting...");
|
||||||
|
|
||||||
|
if (_connection is not null)
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
|
||||||
|
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
|
||||||
|
WireConnectionEvents(_connection);
|
||||||
|
await _connection.ConnectAsync();
|
||||||
|
|
||||||
|
var channels = await _apiClient.GetChannelsAsync();
|
||||||
|
onStatus("Connected");
|
||||||
|
|
||||||
|
// Join default channel + fetch history
|
||||||
|
_joinedChannels.Clear();
|
||||||
|
_joinedChannels.Add(HubConstants.DefaultChannel);
|
||||||
|
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
|
||||||
|
|
||||||
|
List<MessageDto> history = [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
|
||||||
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
_apiClient.Dispose();
|
// History might not be available
|
||||||
_apiClient = null;
|
|
||||||
throw; // Caller handles saved-session expiry
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else if (info.IsRegister)
|
|
||||||
{
|
|
||||||
loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
loginResponse = await _apiClient.LoginAsync(info.Username, info.Password);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-persist rotated refresh tokens for Remember Me
|
return new ConnectResult(loginResponse, channels, history);
|
||||||
_apiClient.OnTokensRefreshed += HandleTokensRefreshed;
|
|
||||||
|
|
||||||
// E2E encryption key
|
|
||||||
onStatus("Fetching encryption key...");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
|
|
||||||
_encryption.SetKey(encryptionKey);
|
|
||||||
Log.Information("E2E encryption key established");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
|
|
||||||
}
|
|
||||||
|
|
||||||
onStatus("Authenticated, connecting...");
|
|
||||||
|
|
||||||
if (_connection is not null)
|
|
||||||
await _connection.DisposeAsync();
|
|
||||||
|
|
||||||
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
|
|
||||||
WireConnectionEvents(_connection);
|
|
||||||
await _connection.ConnectAsync();
|
|
||||||
|
|
||||||
var channels = await _apiClient.GetChannelsAsync();
|
|
||||||
onStatus("Connected");
|
|
||||||
|
|
||||||
// Join default channel + fetch history
|
|
||||||
_joinedChannels.Clear();
|
|
||||||
_joinedChannels.Add(HubConstants.DefaultChannel);
|
|
||||||
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
|
|
||||||
|
|
||||||
List<MessageDto> history = [];
|
|
||||||
try
|
|
||||||
{
|
|
||||||
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// History might not be available
|
if (_connection is not null)
|
||||||
}
|
{
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
_connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
return new ConnectResult(loginResponse, channels, history);
|
_apiClient.Dispose();
|
||||||
|
_apiClient = null;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cleanup ───────────────────────────────────────────────────────────
|
// ── Cleanup ───────────────────────────────────────────────────────────
|
||||||
@@ -228,7 +234,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
private void WireConnectionEvents(EchoHubConnection connection)
|
private void WireConnectionEvents(EchoHubConnection connection)
|
||||||
{
|
{
|
||||||
connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg);
|
connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg);
|
||||||
connection.OnUserJoined += (ch, user) => UserJoined?.Invoke(ch, user);
|
connection.OnUserJoined += (ch, user, presence) => UserJoined?.Invoke(ch, user, presence);
|
||||||
connection.OnUserLeft += (ch, user) => UserLeft?.Invoke(ch, user);
|
connection.OnUserLeft += (ch, user) => UserLeft?.Invoke(ch, user);
|
||||||
connection.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p);
|
connection.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p);
|
||||||
connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason);
|
connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
private readonly ClientEncryptionService _encryption;
|
private readonly ClientEncryptionService _encryption;
|
||||||
|
|
||||||
public event Action<MessageDto>? OnMessageReceived;
|
public event Action<MessageDto>? OnMessageReceived;
|
||||||
public event Action<string, string>? OnUserJoined;
|
public event Action<string, string, UserPresenceDto?>? OnUserJoined;
|
||||||
public event Action<string, string>? OnUserLeft;
|
public event Action<string, string>? OnUserLeft;
|
||||||
public event Action<ChannelDto>? OnChannelUpdated;
|
public event Action<ChannelDto>? OnChannelUpdated;
|
||||||
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
||||||
@@ -70,9 +70,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
OnMessageReceived?.Invoke(decrypted);
|
OnMessageReceived?.Invoke(decrypted);
|
||||||
});
|
});
|
||||||
|
|
||||||
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
|
_connection.On<string, string, UserPresenceDto?>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) =>
|
||||||
{
|
{
|
||||||
OnUserJoined?.Invoke(channelName, username);
|
OnUserJoined?.Invoke(channelName, username, presence);
|
||||||
});
|
});
|
||||||
|
|
||||||
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) =>
|
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) =>
|
||||||
@@ -136,8 +136,10 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||||
{
|
{
|
||||||
var messages = await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
|
var result = await _connection.InvokeAsync<JoinChannelResult>("JoinChannel", channelName);
|
||||||
return DecryptMessages(messages);
|
if (!result.Success)
|
||||||
|
throw new InvalidOperationException(result.Error ?? "Failed to join channel.");
|
||||||
|
return DecryptMessages(result.History);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LeaveChannelAsync(string channelName)
|
public async Task LeaveChannelAsync(string channelName)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures the application's directory is on the system PATH so users
|
||||||
|
/// can run 'echohub' from any terminal session.
|
||||||
|
/// </summary>
|
||||||
|
public static class PathSetup
|
||||||
|
{
|
||||||
|
private const string PathMarker = "# Added by EchoHub";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the app directory is on PATH; if not, adds it persistently.
|
||||||
|
/// On Windows: modifies user-level PATH environment variable.
|
||||||
|
/// On Linux/macOS: appends an export line to shell profile files.
|
||||||
|
/// </summary>
|
||||||
|
public static void EnsureOnPath()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var appDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
|
||||||
|
if (IsOnPath(appDir))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
AddToWindowsPath(appDir);
|
||||||
|
else
|
||||||
|
AddToUnixPath(appDir);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Debug(ex, "Could not add app directory to PATH");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOnPath(string directory)
|
||||||
|
{
|
||||||
|
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||||
|
var separator = OperatingSystem.IsWindows() ? ';' : ':';
|
||||||
|
|
||||||
|
return pathVar
|
||||||
|
.Split(separator, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Any(p => string.Equals(
|
||||||
|
p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
|
||||||
|
directory,
|
||||||
|
OperatingSystem.IsWindows()
|
||||||
|
? StringComparison.OrdinalIgnoreCase
|
||||||
|
: StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddToWindowsPath(string directory)
|
||||||
|
{
|
||||||
|
var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? "";
|
||||||
|
|
||||||
|
// Double-check against user PATH specifically (process PATH includes system + user)
|
||||||
|
if (userPath.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Any(p => string.Equals(p.TrimEnd('\\', '/'), directory, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var newPath = string.IsNullOrEmpty(userPath) ? directory : userPath + ";" + directory;
|
||||||
|
Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.User);
|
||||||
|
Log.Information("Added {Directory} to user PATH", directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddToUnixPath(string directory)
|
||||||
|
{
|
||||||
|
var exportLine = $"export PATH=\"{directory}:$PATH\" {PathMarker}";
|
||||||
|
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
|
||||||
|
// Target the most common shell profiles
|
||||||
|
string[] profiles = [
|
||||||
|
Path.Combine(home, ".profile"),
|
||||||
|
Path.Combine(home, ".bashrc"),
|
||||||
|
Path.Combine(home, ".zshrc")
|
||||||
|
];
|
||||||
|
|
||||||
|
var added = false;
|
||||||
|
foreach (var profile in profiles)
|
||||||
|
{
|
||||||
|
if (!File.Exists(profile))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var content = File.ReadAllText(profile);
|
||||||
|
if (content.Contains(directory))
|
||||||
|
continue; // Already present (manual or previous run)
|
||||||
|
|
||||||
|
File.AppendAllText(profile, $"\n{exportLine}\n");
|
||||||
|
added = true;
|
||||||
|
Log.Information("Added PATH export to {Profile}", profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no profile existed, create .profile
|
||||||
|
if (!added)
|
||||||
|
{
|
||||||
|
var fallback = Path.Combine(home, ".profile");
|
||||||
|
File.AppendAllText(fallback, $"\n{exportLine}\n");
|
||||||
|
Log.Information("Created PATH export in {Profile}", fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages pre-update backups and rollback restoration for the auto-updater.
|
||||||
|
/// Backup location: ~/.echohub/update-backup/
|
||||||
|
/// </summary>
|
||||||
|
public static class UpdateBackupService
|
||||||
|
{
|
||||||
|
private static readonly string BackupDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
".echohub", "update-backup");
|
||||||
|
|
||||||
|
private static readonly string BackupZipPath = Path.Combine(BackupDir, "backup.zip");
|
||||||
|
private static readonly string BackupInfoPath = Path.Combine(BackupDir, "backup-info.json");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if a backup exists from a recent update (set at startup).
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsPostUpdate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a ZIP backup of the current app directory before an update.
|
||||||
|
/// Deletes any previous backup first. Uses fastest compression for speed.
|
||||||
|
/// </summary>
|
||||||
|
public static void CreateBackup()
|
||||||
|
{
|
||||||
|
var appDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
var version = UpdateChecker.CurrentVersion;
|
||||||
|
|
||||||
|
if (Directory.Exists(BackupDir))
|
||||||
|
Directory.Delete(BackupDir, true);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(BackupDir);
|
||||||
|
|
||||||
|
Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version);
|
||||||
|
|
||||||
|
ZipFile.CreateFromDirectory(appDir, BackupZipPath, CompressionLevel.Fastest, includeBaseDirectory: false);
|
||||||
|
|
||||||
|
var info = new BackupInfo(version, appDir, DateTimeOffset.UtcNow);
|
||||||
|
var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo);
|
||||||
|
File.WriteAllText(BackupInfoPath, json);
|
||||||
|
|
||||||
|
Log.Information("Backup created at {BackupPath}", BackupZipPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if a valid backup exists (both ZIP and metadata file present).
|
||||||
|
/// </summary>
|
||||||
|
public static bool BackupExists()
|
||||||
|
=> File.Exists(BackupZipPath) && File.Exists(BackupInfoPath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads backup metadata. Returns null if no backup exists or metadata is unreadable.
|
||||||
|
/// </summary>
|
||||||
|
public static BackupInfo? GetBackupInfo()
|
||||||
|
{
|
||||||
|
if (!File.Exists(BackupInfoPath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(BackupInfoPath);
|
||||||
|
return JsonSerializer.Deserialize(json, BackupJsonContext.Default.BackupInfo);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to read backup metadata");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restores the backup ZIP to the app directory, then restarts the process.
|
||||||
|
/// This method does not return — it calls Environment.Exit(0).
|
||||||
|
/// </summary>
|
||||||
|
public static void RestoreBackup()
|
||||||
|
{
|
||||||
|
var info = GetBackupInfo()
|
||||||
|
?? throw new InvalidOperationException("No backup metadata found");
|
||||||
|
|
||||||
|
var appDir = info.AppDirectory;
|
||||||
|
Log.Information("Restoring backup v{Version} to {AppDir}", info.Version, appDir);
|
||||||
|
|
||||||
|
// On Windows, rename the running executable so extraction can overwrite it
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
var currentExe = Environment.ProcessPath;
|
||||||
|
if (!string.IsNullOrEmpty(currentExe) && File.Exists(currentExe))
|
||||||
|
{
|
||||||
|
var oldExe = currentExe + ".old";
|
||||||
|
if (File.Exists(oldExe))
|
||||||
|
File.Delete(oldExe);
|
||||||
|
File.Move(currentExe, oldExe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ZipFile.ExtractToDirectory(BackupZipPath, appDir, overwriteFiles: true);
|
||||||
|
|
||||||
|
// Restore execute permission on Unix
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
var exePath = Environment.ProcessPath
|
||||||
|
?? Path.Combine(appDir, "EchoHub.Client");
|
||||||
|
|
||||||
|
if (File.Exists(exePath))
|
||||||
|
{
|
||||||
|
var mode = File.GetUnixFileMode(exePath);
|
||||||
|
File.SetUnixFileMode(exePath, mode | UnixFileMode.UserExecute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the restored version and exit
|
||||||
|
var processPath = Environment.ProcessPath
|
||||||
|
?? Path.Combine(appDir, "EchoHub.Client");
|
||||||
|
|
||||||
|
Log.Information("Launching restored version v{Version}: {Path}", info.Version, processPath);
|
||||||
|
Process.Start(new ProcessStartInfo(processPath) { UseShellExecute = false });
|
||||||
|
Environment.Exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the backup directory and all contents.
|
||||||
|
/// </summary>
|
||||||
|
public static void DeleteBackup()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(BackupDir))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(BackupDir, true);
|
||||||
|
Log.Information("Update backup deleted");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to delete update backup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BackupInfo(
|
||||||
|
string Version,
|
||||||
|
string AppDirectory,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonSerializable(typeof(BackupInfo))]
|
||||||
|
internal partial class BackupJsonContext : System.Text.Json.Serialization.JsonSerializerContext;
|
||||||
@@ -5,6 +5,7 @@ using EchoHub.Client.UI.Dialogs;
|
|||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
|
||||||
namespace EchoHub.Client.Services;
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
private readonly Updater _updater;
|
private readonly Updater _updater;
|
||||||
private readonly IApplication _app;
|
private readonly IApplication _app;
|
||||||
private UpdateProgressDialog? _progressDialog;
|
private UpdateProgressDialog? _progressDialog;
|
||||||
|
private bool _manualCheck;
|
||||||
|
|
||||||
|
|
||||||
public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
||||||
|
|
||||||
@@ -37,27 +40,70 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task CheckNowAsync()
|
||||||
|
{
|
||||||
|
_manualCheck = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _updater.CheckForUpdateAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_manualCheck = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnUpdateAvailable(string version, string changelogUrl)
|
private async void OnUpdateAvailable(string version, string changelogUrl)
|
||||||
{
|
{
|
||||||
Log.Information("Update available: v{Version}", version);
|
Log.Information("Update available: v{Version}", version);
|
||||||
|
|
||||||
var confirmed = false;
|
|
||||||
_app.Invoke(() =>
|
_app.Invoke(() =>
|
||||||
{
|
{
|
||||||
confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
|
var confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
|
||||||
|
|
||||||
|
|
||||||
if (confirmed)
|
if (confirmed)
|
||||||
{
|
{
|
||||||
_progressDialog = new UpdateProgressDialog(_app, version);
|
_progressDialog = new UpdateProgressDialog(_app, version);
|
||||||
|
|
||||||
// Start the update; progress is reported via OnProgressChanged
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
|
// Create backup before the update starts
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Creating backup..."));
|
||||||
|
UpdateBackupService.CreateBackup();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Failed to create pre-update backup");
|
||||||
|
|
||||||
|
var proceed = false;
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
proceed = MessageBox.Query(
|
||||||
|
_app,
|
||||||
|
"Backup Warning",
|
||||||
|
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
|
||||||
|
"Continue", "Cancel") == 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!proceed)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
_progressDialog?.Close();
|
||||||
|
_progressDialog = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Downloading update..."));
|
||||||
await _updater.UpdateAsync();
|
await _updater.UpdateAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
_progressDialog?.Show();
|
_progressDialog.Show();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -83,15 +129,52 @@ public sealed class UpdateChecker : IDisposable
|
|||||||
private void OnNoUpdateAvailable()
|
private void OnNoUpdateAvailable()
|
||||||
{
|
{
|
||||||
Log.Debug("No update available");
|
Log.Debug("No update available");
|
||||||
|
if (_manualCheck)
|
||||||
|
{
|
||||||
|
_app.Invoke(() =>
|
||||||
|
{
|
||||||
|
MessageBox.Query(_app, "Check for Updates", $"You are already on the latest version (v{CurrentVersion}).", "OK");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnException(Exception exception)
|
private void OnException(Exception exception)
|
||||||
{
|
{
|
||||||
Log.Error(exception, "Update check failed");
|
Log.Error(exception, "Update failed");
|
||||||
_app.Invoke(() =>
|
_app.Invoke(() =>
|
||||||
{
|
{
|
||||||
_progressDialog?.Close();
|
_progressDialog?.Close();
|
||||||
_progressDialog = null;
|
_progressDialog = null;
|
||||||
|
|
||||||
|
if (UpdateBackupService.BackupExists())
|
||||||
|
{
|
||||||
|
var restore = MessageBox.Query(
|
||||||
|
_app,
|
||||||
|
"Update Failed",
|
||||||
|
$"The update failed: {exception.Message}\n\n"
|
||||||
|
+ "A backup of the previous version is available.\nRestore now? (The app will restart.)",
|
||||||
|
"Restore", "Cancel");
|
||||||
|
|
||||||
|
if (restore == 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UpdateBackupService.RestoreBackup();
|
||||||
|
// RestoreBackup calls Environment.Exit(0)
|
||||||
|
}
|
||||||
|
catch (Exception restoreEx)
|
||||||
|
{
|
||||||
|
Log.Error(restoreEx, "Backup restoration failed");
|
||||||
|
MessageBox.ErrorQuery(_app, "Restore Failed",
|
||||||
|
$"Could not restore backup: {restoreEx.Message}\n\nYou may need to re-download the application.", "OK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MessageBox.ErrorQuery(_app, "Update Failed",
|
||||||
|
$"The update failed: {exception.Message}\n\nYou may need to re-download the application.", "OK");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public static partial class ChatColors
|
|||||||
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
|
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
|
||||||
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
|
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
|
||||||
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
|
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
|
||||||
|
public static readonly Attribute ChannelRefAttr = new(new Color(100, 200, 255), Color.None);
|
||||||
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
|
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
|
||||||
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
|
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
|
||||||
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
|
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
|
||||||
@@ -21,8 +22,8 @@ public static partial class ChatColors
|
|||||||
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
|
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
/// Split text around @mentions and #channels, giving each the appropriate accent color.
|
||||||
/// Non-mention text uses the provided default color.
|
/// Non-special text uses the provided default color.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
|
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
|
||||||
{
|
{
|
||||||
@@ -38,12 +39,44 @@ public static partial class ChatColors
|
|||||||
lastIndex = match.Index + match.Length;
|
lastIndex = match.Index + match.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add remaining text after the last mention (or all text if no mentions found)
|
||||||
if (lastIndex < text.Length)
|
if (lastIndex < text.Length)
|
||||||
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
|
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
|
||||||
|
|
||||||
|
// Second pass: highlight #channels in non-mention segments
|
||||||
|
var mentionSegments = segments;
|
||||||
|
segments = [];
|
||||||
|
foreach (var seg in mentionSegments)
|
||||||
|
{
|
||||||
|
if (seg.Color != null && seg.Color != defaultColor)
|
||||||
|
{
|
||||||
|
// Already colored (mention) — keep as-is
|
||||||
|
segments.Add(seg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int segLast = 0;
|
||||||
|
foreach (Match match in ChannelRefRegex().Matches(seg.Text))
|
||||||
|
{
|
||||||
|
if (match.Index > segLast)
|
||||||
|
segments.Add(new ChatSegment(seg.Text[segLast..match.Index], defaultColor));
|
||||||
|
|
||||||
|
segments.Add(new ChatSegment(match.Value, ChannelRefAttr));
|
||||||
|
segLast = match.Index + match.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segLast < seg.Text.Length)
|
||||||
|
segments.Add(new ChatSegment(seg.Text[segLast..], defaultColor));
|
||||||
|
}
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
[GeneratedRegex(@"@[\w-]+")]
|
// @mention — not preceded by a word char (avoids emails)
|
||||||
|
[GeneratedRegex(@"(?<!\w)@[\w-]+")]
|
||||||
private static partial Regex MentionRegex();
|
private static partial Regex MentionRegex();
|
||||||
|
|
||||||
|
// #channel — not preceded by a word char, must contain at least one letter (avoids hex colors / issue numbers)
|
||||||
|
[GeneratedRegex(@"(?<!\w)#(?=.*[a-zA-Z])[\w-]+")]
|
||||||
|
private static partial Regex ChannelRefRegex();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using Terminal.Gui.Drawing;
|
using Terminal.Gui.Drawing;
|
||||||
@@ -18,6 +19,9 @@ public partial class ChatLine
|
|||||||
public string? AttachmentUrl { get; set; }
|
public string? AttachmentUrl { get; set; }
|
||||||
public string? AttachmentFileName { get; set; }
|
public string? AttachmentFileName { get; set; }
|
||||||
public MessageType? Type { get; set; }
|
public MessageType? Type { get; set; }
|
||||||
|
public string? SenderUsername { get; set; }
|
||||||
|
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
||||||
|
public int ContinuationIndent { get; set; }
|
||||||
|
|
||||||
public ChatLine(string plainText)
|
public ChatLine(string plainText)
|
||||||
{
|
{
|
||||||
@@ -42,59 +46,87 @@ public partial class ChatLine
|
|||||||
if (width <= 0 || TextLength <= width)
|
if (width <= 0 || TextLength <= width)
|
||||||
return [this];
|
return [this];
|
||||||
|
|
||||||
var results = new List<ChatLine>();
|
var tokens = new List<(string grapheme, Attribute? color)>();
|
||||||
var currentSegments = new List<ChatSegment>();
|
|
||||||
int col = 0;
|
|
||||||
|
|
||||||
foreach (var segment in Segments)
|
foreach (var segment in Segments)
|
||||||
|
foreach (var g in GraphemeHelper.GetGraphemes(segment.Text))
|
||||||
|
tokens.Add((g, segment.Color));
|
||||||
|
|
||||||
|
var results = new List<ChatLine>();
|
||||||
|
int pos = 0;
|
||||||
|
bool firstLine = true;
|
||||||
|
|
||||||
|
while (pos < tokens.Count)
|
||||||
{
|
{
|
||||||
var text = segment.Text;
|
// First line uses the full width
|
||||||
int chunkStart = 0;
|
int col = firstLine ? 0 : continuationIndent;
|
||||||
int charPos = 0;
|
int lastSpaceIdx = -1;
|
||||||
|
|
||||||
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
int i = pos;
|
||||||
|
for (; i < tokens.Count; i++)
|
||||||
{
|
{
|
||||||
var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
|
var graphemeCols = Math.Max(tokens[i].grapheme.GetColumns(), 1);
|
||||||
|
if (col + graphemeCols > width) break;
|
||||||
if (col + graphemeCols > width)
|
if (tokens[i].grapheme == " ") lastSpaceIdx = i;
|
||||||
{
|
|
||||||
if (charPos > chunkStart)
|
|
||||||
currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
|
|
||||||
|
|
||||||
results.Add(new ChatLine(currentSegments));
|
|
||||||
currentSegments = [];
|
|
||||||
|
|
||||||
if (continuationIndent > 0)
|
|
||||||
{
|
|
||||||
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
|
||||||
col = continuationIndent;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
col = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
chunkStart = charPos;
|
|
||||||
}
|
|
||||||
|
|
||||||
col += graphemeCols;
|
col += graphemeCols;
|
||||||
charPos += grapheme.Length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chunkStart < text.Length)
|
int lineEnd, nextPos;
|
||||||
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
|
if (i == tokens.Count)
|
||||||
|
{
|
||||||
|
// All remaining tokens fit on this line.
|
||||||
|
lineEnd = tokens.Count;
|
||||||
|
nextPos = tokens.Count;
|
||||||
|
}
|
||||||
|
else if (lastSpaceIdx >= pos)
|
||||||
|
{
|
||||||
|
// Break at the last space that fit, skip the space itself.
|
||||||
|
lineEnd = lastSpaceIdx;
|
||||||
|
nextPos = lastSpaceIdx + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No space found, break word.
|
||||||
|
lineEnd = Math.Max(i, pos + 1);
|
||||||
|
nextPos = lineEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
var segments = new List<ChatSegment>();
|
||||||
|
if (!firstLine && continuationIndent > 0)
|
||||||
|
segments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
||||||
|
|
||||||
|
// Rebuild segments by grouping consecutive same-color tokens.
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
int groupStart = pos;
|
||||||
|
while (groupStart < lineEnd)
|
||||||
|
{
|
||||||
|
var color = tokens[groupStart].color;
|
||||||
|
sb.Clear();
|
||||||
|
int groupEnd = groupStart;
|
||||||
|
while (groupEnd < lineEnd && tokens[groupEnd].color == color)
|
||||||
|
{
|
||||||
|
sb.Append(tokens[groupEnd].grapheme);
|
||||||
|
groupEnd++;
|
||||||
|
}
|
||||||
|
segments.Add(new ChatSegment(sb.ToString(), color));
|
||||||
|
groupStart = groupEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.Add(new ChatLine(segments));
|
||||||
|
pos = nextPos;
|
||||||
|
firstLine = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentSegments.Count > 0)
|
if (results.Count == 0)
|
||||||
results.Add(new ChatLine(currentSegments));
|
return [this];
|
||||||
|
|
||||||
// Propagate attachment/type metadata to all wrapped lines so they remain clickable
|
// Propagate metadata to all wrapped lines so they remain clickable
|
||||||
foreach (var wrapped in results)
|
foreach (var wrapped in results)
|
||||||
{
|
{
|
||||||
wrapped.AttachmentUrl = AttachmentUrl;
|
wrapped.AttachmentUrl = AttachmentUrl;
|
||||||
wrapped.AttachmentFileName = AttachmentFileName;
|
wrapped.AttachmentFileName = AttachmentFileName;
|
||||||
wrapped.Type = Type;
|
wrapped.Type = Type;
|
||||||
wrapped.MessageId = MessageId;
|
wrapped.MessageId = MessageId;
|
||||||
|
wrapped.SenderUsername = SenderUsername;
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -259,6 +259,9 @@ public sealed class ChatMessageManager
|
|||||||
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var l in lines)
|
||||||
|
l.ContinuationIndent = indent.Length;
|
||||||
|
|
||||||
if (message.Embeds is { Count: > 0 })
|
if (message.Embeds is { Count: > 0 })
|
||||||
{
|
{
|
||||||
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||||
@@ -269,7 +272,10 @@ public sealed class ChatMessageManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
line.MessageId = message.Id;
|
line.MessageId = message.Id;
|
||||||
|
line.SenderUsername = message.SenderUsername;
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
||||||
{
|
{
|
||||||
@@ -326,18 +332,20 @@ public sealed class ChatMessageManager
|
|||||||
int textWidth = chatWidth - indentCols - borderCols;
|
int textWidth = chatWidth - indentCols - borderCols;
|
||||||
if (textWidth < 20) textWidth = 20;
|
if (textWidth < 20) textWidth = 20;
|
||||||
|
|
||||||
|
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
|
||||||
|
|
||||||
void AddTextLine(string text, Attribute? color)
|
void AddTextLine(string text, Attribute? color)
|
||||||
{
|
{
|
||||||
lines.Add(new ChatLine(
|
lines.Add(new ChatLine(
|
||||||
[
|
[
|
||||||
new ChatSegment(indent, null),
|
new ChatSegment(indent, null),
|
||||||
new ChatSegment(border, ChatColors.EmbedBorderAttr),
|
new ChatSegment(border, borderAttr),
|
||||||
new ChatSegment(text, color)
|
new ChatSegment(text, color)
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
|
AddTextLine(embed.SiteName, borderAttr);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Title))
|
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public sealed class AudioPlayerDialog
|
|||||||
|
|
||||||
public static void Show(IApplication app, AudioPlaybackService audioService, string filePath, string fileName)
|
public static void Show(IApplication app, AudioPlaybackService audioService, string filePath, string fileName)
|
||||||
{
|
{
|
||||||
var dialog = new Dialog { Title = "Audio Player", Width = 52, Height = 14 };
|
var dialog = new Dialog { Title = "Audio Player", Width = 52, Height = 12 };
|
||||||
|
|
||||||
// ── File name ──
|
// ── File name ──
|
||||||
var fileLabel = new Label
|
var fileLabel = new Label
|
||||||
@@ -61,38 +61,47 @@ public sealed class AudioPlayerDialog
|
|||||||
};
|
};
|
||||||
|
|
||||||
byte currentVolume = 50;
|
byte currentVolume = 50;
|
||||||
var volumeBar = new ProgressBar
|
|
||||||
{
|
|
||||||
X = 14,
|
|
||||||
Y = 7,
|
|
||||||
Width = 20,
|
|
||||||
Height = 1,
|
|
||||||
Fraction = currentVolume / 100f,
|
|
||||||
ProgressBarStyle = ProgressBarStyle.Continuous
|
|
||||||
};
|
|
||||||
|
|
||||||
var volumePercentLabel = new Label
|
|
||||||
{
|
|
||||||
Text = $"{currentVolume}%",
|
|
||||||
X = 35,
|
|
||||||
Y = 7,
|
|
||||||
Width = 5
|
|
||||||
};
|
|
||||||
|
|
||||||
var volDownButton = new Button
|
var volDownButton = new Button
|
||||||
{
|
{
|
||||||
Text = "-",
|
Text = "-",
|
||||||
X = 10,
|
X = 10,
|
||||||
Y = 7,
|
Y = 7,
|
||||||
Width = 3
|
Width = 1,
|
||||||
|
Height = 1,
|
||||||
|
NoDecorations = true,
|
||||||
|
NoPadding = true,
|
||||||
|
ShadowStyle = ShadowStyle.None
|
||||||
|
};
|
||||||
|
|
||||||
|
var volumeBar = new ProgressBar
|
||||||
|
{
|
||||||
|
X = 12,
|
||||||
|
Y = 7,
|
||||||
|
Width = 22,
|
||||||
|
Height = 1,
|
||||||
|
Fraction = currentVolume / 100f,
|
||||||
|
ProgressBarStyle = ProgressBarStyle.Continuous
|
||||||
};
|
};
|
||||||
|
|
||||||
var volUpButton = new Button
|
var volUpButton = new Button
|
||||||
{
|
{
|
||||||
Text = "+",
|
Text = "+",
|
||||||
X = 41,
|
X = 35,
|
||||||
Y = 7,
|
Y = 7,
|
||||||
Width = 3
|
Width = 1,
|
||||||
|
Height = 1,
|
||||||
|
NoDecorations = true,
|
||||||
|
NoPadding = true,
|
||||||
|
ShadowStyle = ShadowStyle.None
|
||||||
|
};
|
||||||
|
|
||||||
|
var volumePercentLabel = new Label
|
||||||
|
{
|
||||||
|
Text = $"{currentVolume}%",
|
||||||
|
X = 37,
|
||||||
|
Y = 7,
|
||||||
|
Width = 5
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Playback controls ──
|
// ── Playback controls ──
|
||||||
@@ -100,22 +109,25 @@ public sealed class AudioPlayerDialog
|
|||||||
{
|
{
|
||||||
Text = "\u25b6 Play",
|
Text = "\u25b6 Play",
|
||||||
X = 2,
|
X = 2,
|
||||||
Y = 10,
|
Y = 9,
|
||||||
IsDefault = true
|
IsDefault = true,
|
||||||
|
ShadowStyle = ShadowStyle.None
|
||||||
};
|
};
|
||||||
|
|
||||||
var stopButton = new Button
|
var stopButton = new Button
|
||||||
{
|
{
|
||||||
Text = "\u25a0 Stop",
|
Text = "\u25a0 Stop",
|
||||||
X = Pos.Right(playButton) + 2,
|
X = Pos.Right(playButton) + 1,
|
||||||
Y = 10
|
Y = 9,
|
||||||
|
ShadowStyle = ShadowStyle.None
|
||||||
};
|
};
|
||||||
|
|
||||||
var closeButton = new Button
|
var closeButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Close",
|
Text = "Close",
|
||||||
X = Pos.Right(stopButton) + 2,
|
X = Pos.Right(stopButton) + 1,
|
||||||
Y = 10
|
Y = 9,
|
||||||
|
ShadowStyle = ShadowStyle.None
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Animation state ──
|
// ── Animation state ──
|
||||||
@@ -128,6 +140,7 @@ public sealed class AudioPlayerDialog
|
|||||||
|
|
||||||
Timer? animationTimer = null;
|
Timer? animationTimer = null;
|
||||||
var isDisposed = false;
|
var isDisposed = false;
|
||||||
|
var isBusy = false;
|
||||||
|
|
||||||
// ── Helper functions ──
|
// ── Helper functions ──
|
||||||
void UpdateWave(bool isActive)
|
void UpdateWave(bool isActive)
|
||||||
@@ -278,6 +291,8 @@ public sealed class AudioPlayerDialog
|
|||||||
playButton.Accepting += (s, e) =>
|
playButton.Accepting += (s, e) =>
|
||||||
{
|
{
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
Task.Run(async () =>
|
Task.Run(async () =>
|
||||||
{
|
{
|
||||||
if (audioService.IsPaused)
|
if (audioService.IsPaused)
|
||||||
@@ -287,6 +302,7 @@ public sealed class AudioPlayerDialog
|
|||||||
{
|
{
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
StartAnimation();
|
StartAnimation();
|
||||||
|
isBusy = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else if (audioService.IsPlaying)
|
else if (audioService.IsPlaying)
|
||||||
@@ -296,6 +312,7 @@ public sealed class AudioPlayerDialog
|
|||||||
{
|
{
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
StopAnimation();
|
StopAnimation();
|
||||||
|
isBusy = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -306,6 +323,7 @@ public sealed class AudioPlayerDialog
|
|||||||
{
|
{
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
StartAnimation();
|
StartAnimation();
|
||||||
|
isBusy = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -314,6 +332,8 @@ public sealed class AudioPlayerDialog
|
|||||||
stopButton.Accepting += (s, e) =>
|
stopButton.Accepting += (s, e) =>
|
||||||
{
|
{
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
Task.Run(async () =>
|
Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await audioService.StopAsync();
|
await audioService.StopAsync();
|
||||||
@@ -321,6 +341,7 @@ public sealed class AudioPlayerDialog
|
|||||||
{
|
{
|
||||||
UpdateStatus();
|
UpdateStatus();
|
||||||
StopAnimation();
|
StopAnimation();
|
||||||
|
isBusy = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -337,6 +358,8 @@ public sealed class AudioPlayerDialog
|
|||||||
volDownButton.Accepting += (s, e) =>
|
volDownButton.Accepting += (s, e) =>
|
||||||
{
|
{
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
var newVol = (byte)Math.Max(0, currentVolume - 10);
|
var newVol = (byte)Math.Max(0, currentVolume - 10);
|
||||||
Task.Run(async () =>
|
Task.Run(async () =>
|
||||||
{
|
{
|
||||||
@@ -345,6 +368,7 @@ public sealed class AudioPlayerDialog
|
|||||||
{
|
{
|
||||||
volumeBar.SetNeedsDraw();
|
volumeBar.SetNeedsDraw();
|
||||||
volumePercentLabel.SetNeedsDraw();
|
volumePercentLabel.SetNeedsDraw();
|
||||||
|
isBusy = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -352,6 +376,8 @@ public sealed class AudioPlayerDialog
|
|||||||
volUpButton.Accepting += (s, e) =>
|
volUpButton.Accepting += (s, e) =>
|
||||||
{
|
{
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
|
if (isBusy) return;
|
||||||
|
isBusy = true;
|
||||||
var newVol = (byte)Math.Min(100, currentVolume + 10);
|
var newVol = (byte)Math.Min(100, currentVolume + 10);
|
||||||
Task.Run(async () =>
|
Task.Run(async () =>
|
||||||
{
|
{
|
||||||
@@ -360,6 +386,7 @@ public sealed class AudioPlayerDialog
|
|||||||
{
|
{
|
||||||
volumeBar.SetNeedsDraw();
|
volumeBar.SetNeedsDraw();
|
||||||
volumePercentLabel.SetNeedsDraw();
|
volumePercentLabel.SetNeedsDraw();
|
||||||
|
isBusy = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ namespace EchoHub.Client.UI.ListSources;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class UserListSource : IListDataSource
|
public class UserListSource : IListDataSource
|
||||||
{
|
{
|
||||||
private readonly List<(string Text, Attribute? NameColor)> _users = [];
|
private readonly List<(string Text, Attribute? NameColor, string Username)> _users = [];
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
public int Count => _users.Count;
|
public int Count => _users.Count;
|
||||||
public int MaxItemLength { get; private set; }
|
public int MaxItemLength { get; private set; }
|
||||||
public bool SuspendCollectionChangedEvent { get; set; }
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
public void Update(List<(string Text, Attribute? NameColor)> users)
|
public void Update(List<(string Text, Attribute? NameColor, string Username)> users)
|
||||||
{
|
{
|
||||||
_users.Clear();
|
_users.Clear();
|
||||||
_users.AddRange(users);
|
_users.AddRange(users);
|
||||||
@@ -28,15 +28,18 @@ public class UserListSource : IListDataSource
|
|||||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string? GetUsername(int index) =>
|
||||||
|
index >= 0 && index < _users.Count ? _users[index].Username : null;
|
||||||
|
|
||||||
public bool IsMarked(int item) => false;
|
public bool IsMarked(int item) => false;
|
||||||
public void SetMark(int item, bool value) { }
|
public void SetMark(int item, bool value) { }
|
||||||
public IList ToList() => _users.Select(u => u.Text).ToList();
|
public IList ToList() => _users.Select(u => (object)u.Text).ToList();
|
||||||
|
|
||||||
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||||
{
|
{
|
||||||
listView.Move(Math.Max(col - viewportX, 0), row);
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
var (text, nameColor) = _users[item];
|
var (text, nameColor, _) = _users[item];
|
||||||
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
||||||
|
|
||||||
// Find where the name starts (after status icon + space + optional role badge)
|
// Find where the name starts (after status icon + space + optional role badge)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using EchoHub.Client.Services;
|
||||||
using EchoHub.Client.Themes;
|
using EchoHub.Client.Themes;
|
||||||
using EchoHub.Client.UI.Chat;
|
using EchoHub.Client.UI.Chat;
|
||||||
using EchoHub.Client.UI.Helpers;
|
using EchoHub.Client.UI.Helpers;
|
||||||
@@ -18,7 +20,7 @@ namespace EchoHub.Client.UI;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Main Terminal.Gui window for the EchoHub chat client.
|
/// Main Terminal.Gui window for the EchoHub chat client.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MainWindow : Runnable
|
public sealed partial class MainWindow : Runnable
|
||||||
{
|
{
|
||||||
private readonly IApplication _app;
|
private readonly IApplication _app;
|
||||||
private readonly ListView _channelList;
|
private readonly ListView _channelList;
|
||||||
@@ -104,6 +106,11 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string>? OnThemeSelected;
|
public event Action<string>? OnThemeSelected;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to check for updates.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnCheckForUpdatesRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired when the user requests to view saved servers.
|
/// Fired when the user requests to view saved servers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -119,6 +126,11 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? OnDeleteChannelRequested;
|
public event Action? OnDeleteChannelRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to rollback to the previous version.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnRollbackRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
|
/// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -129,6 +141,16 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string, string>? OnFileDownloadRequested;
|
public event Action<string, string>? OnFileDownloadRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string>? OnUserProfileRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user activates a #channel reference in a message. Parameter is the channel name.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string>? OnChannelJoinRequested;
|
||||||
|
|
||||||
public MainWindow(IApplication app, ChatMessageManager messageManager)
|
public MainWindow(IApplication app, ChatMessageManager messageManager)
|
||||||
{
|
{
|
||||||
_app = app;
|
_app = app;
|
||||||
@@ -239,6 +261,7 @@ public sealed class MainWindow : Runnable
|
|||||||
};
|
};
|
||||||
_usersListSource = new UserListSource();
|
_usersListSource = new UserListSource();
|
||||||
_usersList.Source = _usersListSource;
|
_usersList.Source = _usersListSource;
|
||||||
|
_usersList.Accepting += OnUsersListAccepting;
|
||||||
_usersFrame.Add(_usersList);
|
_usersFrame.Add(_usersList);
|
||||||
Add(_usersFrame);
|
Add(_usersFrame);
|
||||||
|
|
||||||
@@ -318,12 +341,20 @@ public sealed class MainWindow : Runnable
|
|||||||
};
|
};
|
||||||
allUserItems.AddRange(themeItems);
|
allUserItems.AddRange(themeItems);
|
||||||
|
|
||||||
|
var fileItems = new List<View>();
|
||||||
|
if (UpdateBackupService.BackupExists())
|
||||||
|
{
|
||||||
|
var info = UpdateBackupService.GetBackupInfo();
|
||||||
|
var label = info is not null ? $"_Rollback to v{info.Version}..." : "_Rollback Update...";
|
||||||
|
fileItems.Add(new MenuItem(label, "Restore previous version", () => OnRollbackRequested?.Invoke(), Key.Empty));
|
||||||
|
fileItems.Add(new Line());
|
||||||
|
}
|
||||||
|
fileItems.Add(new MenuItem($"_Check for Updates", "Check for new version", () => OnCheckForUpdatesRequested?.Invoke(), Key.Empty));
|
||||||
|
fileItems.Add(new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty));
|
||||||
|
|
||||||
var menuBar = new MenuBar(
|
var menuBar = new MenuBar(
|
||||||
[
|
[
|
||||||
new MenuBarItem("_File",
|
new MenuBarItem("_File", fileItems),
|
||||||
[
|
|
||||||
new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty)
|
|
||||||
]),
|
|
||||||
new MenuBarItem("_Server", new View[]
|
new MenuBarItem("_Server", new View[]
|
||||||
{
|
{
|
||||||
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
|
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
|
||||||
@@ -391,17 +422,66 @@ public sealed class MainWindow : Runnable
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var line = source.GetLine(index.Value);
|
var line = source.GetLine(index.Value);
|
||||||
if (line?.AttachmentUrl is null || line.AttachmentFileName is null)
|
if (line is null) return;
|
||||||
return;
|
|
||||||
|
|
||||||
if (line.Type == MessageType.Audio)
|
// Audio/file attachments take priority
|
||||||
|
if (line.AttachmentUrl is not null && line.AttachmentFileName is not null)
|
||||||
{
|
{
|
||||||
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
if (line.Type == MessageType.Audio)
|
||||||
|
{
|
||||||
|
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Type == MessageType.File)
|
||||||
|
{
|
||||||
|
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lineText = line.ToString();
|
||||||
|
|
||||||
|
// Check for @mention — open mentioned user's profile
|
||||||
|
// Negative lookbehind prevents matching emails (user@domain)
|
||||||
|
var mentionMatch = ClickMentionRegex().Match(lineText);
|
||||||
|
if (mentionMatch.Success)
|
||||||
|
{
|
||||||
|
OnUserProfileRequested?.Invoke(mentionMatch.Groups[1].Value);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for #channel — join/switch to that channel
|
||||||
|
// Require at least one letter to avoid matching hex colors (#ff0000) or issue numbers (#123)
|
||||||
|
var channelMatch = ClickChannelRegex().Match(lineText);
|
||||||
|
if (channelMatch.Success)
|
||||||
|
{
|
||||||
|
OnChannelJoinRequested?.Invoke(channelMatch.Groups[1].Value);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: open sender's profile
|
||||||
|
if (line.SenderUsername is not null)
|
||||||
|
{
|
||||||
|
OnUserProfileRequested?.Invoke(line.SenderUsername);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
else if (line.Type == MessageType.File)
|
}
|
||||||
|
|
||||||
|
private void OnUsersListAccepting(object? sender, CommandEventArgs e)
|
||||||
|
{
|
||||||
|
var index = _usersList.SelectedItem;
|
||||||
|
if (!index.HasValue || index.Value < 0 || index.Value >= _usersListSource.Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var username = _usersListSource.GetUsername(index.Value);
|
||||||
|
if (username is not null)
|
||||||
{
|
{
|
||||||
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
OnUserProfileRequested?.Invoke(username);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -753,7 +833,7 @@ public sealed class MainWindow : Runnable
|
|||||||
if (width > 0)
|
if (width > 0)
|
||||||
{
|
{
|
||||||
foreach (var line in messages)
|
foreach (var line in messages)
|
||||||
source.AddRange(line.Wrap(width));
|
source.AddRange(line.Wrap(width, line.ContinuationIndent));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -848,9 +928,11 @@ public sealed class MainWindow : Runnable
|
|||||||
ServerRole.Mod => "\u2740", // ❀
|
ServerRole.Mod => "\u2740", // ❀
|
||||||
_ => ""
|
_ => ""
|
||||||
};
|
};
|
||||||
var text = $"{statusIcon} {roleTag}{name}";
|
var text = roleTag.Length > 0
|
||||||
|
? $"{statusIcon} {roleTag} {name}"
|
||||||
|
: $"{statusIcon} {name}";
|
||||||
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
|
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
|
||||||
return (text, nameColor);
|
return (text, nameColor, u.Username);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
_usersListSource.Update(displayItems);
|
_usersListSource.Update(displayItems);
|
||||||
@@ -858,4 +940,11 @@ public sealed class MainWindow : Runnable
|
|||||||
_usersFrame.Title = $"Users ({users.Count})";
|
_usersFrame.Title = $"Users ({users.Count})";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @mention — not preceded by a word char (avoids emails)
|
||||||
|
[GeneratedRegex(@"(?<!\w)@([\w-]+)")]
|
||||||
|
private static partial Regex ClickMentionRegex();
|
||||||
|
|
||||||
|
// #channel — not preceded by a word char, must contain at least one letter (avoids hex colors / issue numbers)
|
||||||
|
[GeneratedRegex(@"(?<!\w)#((?=.*[a-zA-Z])[\w-]+)")]
|
||||||
|
private static partial Regex ClickChannelRegex();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace EchoHub.Core.Contracts;
|
|||||||
public interface IChatBroadcaster
|
public interface IChatBroadcaster
|
||||||
{
|
{
|
||||||
Task SendMessageToChannelAsync(string channelName, MessageDto message);
|
Task SendMessageToChannelAsync(string channelName, MessageDto message);
|
||||||
Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null);
|
Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null);
|
||||||
Task SendUserLeftAsync(string channelName, string username);
|
Task SendUserLeftAsync(string channelName, string username);
|
||||||
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||||
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
|
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ public interface IChatService
|
|||||||
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
||||||
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||||
|
|
||||||
// Query operations (used by IRC gateway for WHOIS, AUTH)
|
// Query operations (used by IRC gateway for WHOIS)
|
||||||
Task<UserProfileDto?> GetUserProfileAsync(string username);
|
|
||||||
Task<List<string>> GetChannelsForUserAsync(string username);
|
Task<List<string>> GetChannelsForUserAsync(string username);
|
||||||
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace EchoHub.Core.Contracts;
|
|||||||
public interface IEchoHubClient
|
public interface IEchoHubClient
|
||||||
{
|
{
|
||||||
Task ReceiveMessage(MessageDto message);
|
Task ReceiveMessage(MessageDto message);
|
||||||
Task UserJoined(string channelName, string username);
|
Task UserJoined(string channelName, string username, UserPresenceDto? presence);
|
||||||
Task UserLeft(string channelName, string username);
|
Task UserLeft(string channelName, string username);
|
||||||
Task ChannelUpdated(ChannelDto channel);
|
Task ChannelUpdated(ChannelDto channel);
|
||||||
Task UserStatusChanged(UserPresenceDto presence);
|
Task UserStatusChanged(UserPresenceDto presence);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
namespace EchoHub.Core.Contracts;
|
||||||
|
|
||||||
|
public interface IUserService
|
||||||
|
{
|
||||||
|
Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null);
|
||||||
|
Task<UserOperationResult> AuthenticateUserAsync(string username, string password);
|
||||||
|
Task<UserProfileDto?> GetUserProfileAsync(string username);
|
||||||
|
Task<UserProfileDto?> GetUserByIdAsync(Guid userId);
|
||||||
|
Task<UserOperationResult> UpdateProfileAsync(Guid userId, string? displayName, string? bio, string? nicknameColor);
|
||||||
|
Task<UserOperationResult> SetAvatarAsync(Guid userId, string asciiArt);
|
||||||
|
}
|
||||||
@@ -39,9 +39,12 @@ public record UpdateTopicRequest(string? Topic);
|
|||||||
|
|
||||||
public record SendUrlRequest(string Url);
|
public record SendUrlRequest(string Url);
|
||||||
|
|
||||||
|
public record JoinChannelResult(bool Success, List<MessageDto> History, string? Error = null);
|
||||||
|
|
||||||
public record EmbedDto(
|
public record EmbedDto(
|
||||||
string? SiteName,
|
string? SiteName,
|
||||||
string? Title,
|
string? Title,
|
||||||
string? Description,
|
string? Description,
|
||||||
string? ImageAscii,
|
string? ImageAscii,
|
||||||
string Url);
|
string Url,
|
||||||
|
string? ThemeColor = null);
|
||||||
|
|||||||
@@ -24,3 +24,20 @@ public record ChannelOperationResult(ChannelDto? Channel, ChannelError? Error, s
|
|||||||
public static ChannelOperationResult Success(ChannelDto channel) => new(channel, null, null);
|
public static ChannelOperationResult Success(ChannelDto channel) => new(channel, null, null);
|
||||||
public static ChannelOperationResult Fail(ChannelError error, string message) => new(null, error, message);
|
public static ChannelOperationResult Fail(ChannelError error, string message) => new(null, error, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum UserError
|
||||||
|
{
|
||||||
|
ValidationFailed,
|
||||||
|
AlreadyExists,
|
||||||
|
NotFound,
|
||||||
|
InvalidCredentials,
|
||||||
|
Banned
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UserOperationResult(UserProfileDto? User, UserError? Error, string? ErrorMessage)
|
||||||
|
{
|
||||||
|
public bool IsSuccess => Error is null;
|
||||||
|
|
||||||
|
public static UserOperationResult Success(UserProfileDto user) => new(user, null, null);
|
||||||
|
public static UserOperationResult Fail(UserError error, string message) => new(null, error, message);
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
|
public async Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
|
||||||
{
|
{
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ public sealed class IrcClientConnection : IAsyncDisposable
|
|||||||
public IrcClientConnection(TcpClient tcpClient, Stream stream)
|
public IrcClientConnection(TcpClient tcpClient, Stream stream)
|
||||||
{
|
{
|
||||||
_tcpClient = tcpClient;
|
_tcpClient = tcpClient;
|
||||||
_reader = new StreamReader(stream, Encoding.UTF8);
|
var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||||
_writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true, NewLine = "\r\n" };
|
_reader = new StreamReader(stream, utf8NoBom);
|
||||||
|
_writer = new StreamWriter(stream, utf8NoBom) { AutoFlush = true, NewLine = "\r\n" };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> ReadLineAsync(CancellationToken ct)
|
public async Task<string?> ReadLineAsync(CancellationToken ct)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public sealed class IrcCommandHandler
|
|||||||
private readonly IrcClientConnection _conn;
|
private readonly IrcClientConnection _conn;
|
||||||
private readonly IrcOptions _options;
|
private readonly IrcOptions _options;
|
||||||
private readonly IChatService _chatService;
|
private readonly IChatService _chatService;
|
||||||
|
private readonly IUserService _userService;
|
||||||
private readonly IChannelService _channelService;
|
private readonly IChannelService _channelService;
|
||||||
private readonly IMessageEncryptionService _encryption;
|
private readonly IMessageEncryptionService _encryption;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
@@ -22,6 +23,7 @@ public sealed class IrcCommandHandler
|
|||||||
IrcClientConnection conn,
|
IrcClientConnection conn,
|
||||||
IrcOptions options,
|
IrcOptions options,
|
||||||
IChatService chatService,
|
IChatService chatService,
|
||||||
|
IUserService userService,
|
||||||
IChannelService channelService,
|
IChannelService channelService,
|
||||||
IMessageEncryptionService encryption,
|
IMessageEncryptionService encryption,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
@@ -29,6 +31,7 @@ public sealed class IrcCommandHandler
|
|||||||
_conn = conn;
|
_conn = conn;
|
||||||
_options = options;
|
_options = options;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
|
_userService = userService;
|
||||||
_channelService = channelService;
|
_channelService = channelService;
|
||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -44,6 +47,8 @@ public sealed class IrcCommandHandler
|
|||||||
line = line.TrimEnd('\r', '\n');
|
line = line.TrimEnd('\r', '\n');
|
||||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
|
||||||
|
_logger.LogDebug("IRC < {Id}: {Line}", _conn.ConnectionId, line);
|
||||||
|
|
||||||
var msg = IrcMessage.Parse(line);
|
var msg = IrcMessage.Parse(line);
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -138,6 +143,15 @@ public sealed class IrcCommandHandler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AUTHENTICATE * = client aborts SASL
|
||||||
|
if (msg.Parameters[0] == "*")
|
||||||
|
{
|
||||||
|
_conn.IsSasl = false;
|
||||||
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
|
||||||
|
":SASL authentication aborted");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var decoded = Convert.FromBase64String(msg.Parameters[0]);
|
var decoded = Convert.FromBase64String(msg.Parameters[0]);
|
||||||
@@ -154,26 +168,39 @@ public sealed class IrcCommandHandler
|
|||||||
var username = (parts[1].Length > 0 ? parts[1] : parts[0]).ToLowerInvariant();
|
var username = (parts[1].Length > 0 ? parts[1] : parts[0]).ToLowerInvariant();
|
||||||
var password = parts[2];
|
var password = parts[2];
|
||||||
|
|
||||||
var result = await _chatService.AuthenticateUserAsync(username, password);
|
_logger.LogDebug("SASL PLAIN auth attempt for user '{Username}' (connection {Id})",
|
||||||
|
username, _conn.ConnectionId);
|
||||||
|
|
||||||
if (result is null)
|
var result = await _userService.AuthenticateUserAsync(username, password);
|
||||||
|
|
||||||
|
// Auth failed — try registering a new account
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
result = await _userService.RegisterUserAsync(username, password);
|
||||||
|
|
||||||
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning("SASL auth/register failed for user '{Username}': {Error} (connection {Id})",
|
||||||
|
username, result.ErrorMessage, _conn.ConnectionId);
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
|
||||||
":SASL authentication failed");
|
$":SASL authentication failed — {result.ErrorMessage}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_conn.Nickname = result.Value.Username;
|
_conn.Nickname = result.User!.Username;
|
||||||
_conn.UserId = result.Value.UserId;
|
_conn.UserId = result.User!.Id;
|
||||||
_conn.IsAuthenticated = true;
|
_conn.IsAuthenticated = true;
|
||||||
|
|
||||||
|
_logger.LogInformation("SASL auth succeeded for user '{Username}' (connection {Id})",
|
||||||
|
username, _conn.ConnectionId);
|
||||||
|
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LOGGEDIN,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LOGGEDIN,
|
||||||
$"{_conn.Hostmask} {username} :You are now logged in as {username}");
|
$"{_conn.Hostmask} {username} :You are now logged in as {username}");
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_SASLSUCCESS,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_SASLSUCCESS,
|
||||||
":SASL authentication successful");
|
":SASL authentication successful");
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(ex, "SASL auth exception for connection {Id}", _conn.ConnectionId);
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
|
||||||
":SASL authentication failed");
|
":SASL authentication failed");
|
||||||
}
|
}
|
||||||
@@ -240,6 +267,10 @@ public sealed class IrcCommandHandler
|
|||||||
|
|
||||||
private async Task TryCompleteRegistrationAsync()
|
private async Task TryCompleteRegistrationAsync()
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("TryCompleteRegistration: CapNeg={Cap}, Registered={Reg}, Authenticated={Auth}, Nick={Nick}, User={User}, Id={Id}",
|
||||||
|
_conn.CapNegotiating, _conn.IsRegistered, _conn.IsAuthenticated,
|
||||||
|
_conn.Nickname, _conn.Username, _conn.ConnectionId);
|
||||||
|
|
||||||
if (_conn.CapNegotiating || _conn.IsRegistered) return;
|
if (_conn.CapNegotiating || _conn.IsRegistered) return;
|
||||||
|
|
||||||
// SASL already authenticated
|
// SASL already authenticated
|
||||||
@@ -260,22 +291,26 @@ public sealed class IrcCommandHandler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _chatService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password);
|
var result = await _userService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password);
|
||||||
|
|
||||||
if (result is null)
|
// Auth failed — try registering a new account
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
result = await _userService.RegisterUserAsync(_conn.Nickname!, _conn.Password);
|
||||||
|
|
||||||
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH,
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH,
|
||||||
":Password incorrect or account not found. Register via the EchoHub client first.");
|
$":{result.ErrorMessage}");
|
||||||
await _conn.SendAsync("ERROR :Authentication failed");
|
await _conn.SendAsync("ERROR :Authentication failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_conn.UserId = result.Value.UserId;
|
_conn.UserId = result.User!.Id;
|
||||||
_conn.Nickname = result.Value.Username;
|
_conn.Nickname = result.User!.Username;
|
||||||
_conn.IsAuthenticated = true;
|
_conn.IsAuthenticated = true;
|
||||||
_conn.IsRegistered = true;
|
_conn.IsRegistered = true;
|
||||||
|
|
||||||
await _chatService.UserConnectedAsync(_conn.ConnectionId, result.Value.UserId, result.Value.Username);
|
await _chatService.UserConnectedAsync(_conn.ConnectionId, result.User!.Id, result.User!.Username);
|
||||||
await SendWelcomeBurstAsync();
|
await SendWelcomeBurstAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +562,7 @@ public sealed class IrcCommandHandler
|
|||||||
if (msg.Parameters.Count < 1) return;
|
if (msg.Parameters.Count < 1) return;
|
||||||
|
|
||||||
var nick = msg.Parameters[^1].ToLowerInvariant();
|
var nick = msg.Parameters[^1].ToLowerInvariant();
|
||||||
var profile = await _chatService.GetUserProfileAsync(nick);
|
var profile = await _userService.GetUserProfileAsync(nick);
|
||||||
|
|
||||||
if (profile is null)
|
if (profile is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -120,10 +120,11 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
chatService = _services.GetRequiredService<IChatService>();
|
chatService = _services.GetRequiredService<IChatService>();
|
||||||
|
var userService = _services.GetRequiredService<IUserService>();
|
||||||
var channelService = _services.GetRequiredService<IChannelService>();
|
var channelService = _services.GetRequiredService<IChannelService>();
|
||||||
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
|
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
|
||||||
var handler = new IrcCommandHandler(
|
var handler = new IrcCommandHandler(
|
||||||
connection, _options, chatService, channelService, encryption, _logger);
|
connection, _options, chatService, userService, channelService, encryption, _logger);
|
||||||
|
|
||||||
await handler.RunAsync(ct);
|
await handler.RunAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
.vs/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.DotSettings.user
|
||||||
@@ -2,6 +2,7 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
@@ -51,6 +52,31 @@ public class JwtTokenService
|
|||||||
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public (string Token, DateTimeOffset ExpiresAt) GenerateAccessToken(UserProfileDto profile)
|
||||||
|
{
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
|
||||||
|
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
var expiresAt = DateTimeOffset.UtcNow.Add(AccessTokenLifetime);
|
||||||
|
|
||||||
|
Claim[] claims =
|
||||||
|
[
|
||||||
|
new(JwtRegisteredClaimNames.Sub, profile.Id.ToString()),
|
||||||
|
new("username", profile.Username),
|
||||||
|
new("display_name", profile.DisplayName ?? profile.Username),
|
||||||
|
new("role", profile.Role.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
];
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: _issuer,
|
||||||
|
audience: _audience,
|
||||||
|
claims: claims,
|
||||||
|
expires: expiresAt.UtcDateTime,
|
||||||
|
signingCredentials: credentials);
|
||||||
|
|
||||||
|
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
public static string GenerateRefreshToken()
|
public static string GenerateRefreshToken()
|
||||||
{
|
{
|
||||||
var randomBytes = new byte[64];
|
var randomBytes = new byte[64];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Auth;
|
using EchoHub.Server.Auth;
|
||||||
@@ -16,94 +16,59 @@ public class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly EchoHubDbContext _db;
|
private readonly EchoHubDbContext _db;
|
||||||
private readonly JwtTokenService _jwt;
|
private readonly JwtTokenService _jwt;
|
||||||
|
private readonly IUserService _userService;
|
||||||
|
|
||||||
public AuthController(EchoHubDbContext db, JwtTokenService jwt)
|
public AuthController(EchoHubDbContext db, JwtTokenService jwt, IUserService userService)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_jwt = jwt;
|
_jwt = jwt;
|
||||||
|
_userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
var result = await _userService.RegisterUserAsync(request.Username, request.Password, request.DisplayName);
|
||||||
return BadRequest(new ErrorResponse("Username and password are required."));
|
if (!result.IsSuccess)
|
||||||
|
return MapUserError(result);
|
||||||
|
|
||||||
if (!ValidationConstants.UsernameRegex().IsMatch(request.Username))
|
var profile = result.User!;
|
||||||
return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens."));
|
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(profile);
|
||||||
|
|
||||||
if (request.Password.Length < 6)
|
|
||||||
return BadRequest(new ErrorResponse("Password must be at least 6 characters."));
|
|
||||||
|
|
||||||
if (request.Password.Length > ValidationConstants.MaxPasswordLength)
|
|
||||||
return BadRequest(new ErrorResponse($"Password must not exceed {ValidationConstants.MaxPasswordLength} characters."));
|
|
||||||
|
|
||||||
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
|
||||||
|
|
||||||
if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
|
||||||
return Conflict(new ErrorResponse("Username is already taken."));
|
|
||||||
|
|
||||||
// First registered user on the server becomes the Owner
|
|
||||||
var isFirstUser = !await _db.Users.AnyAsync();
|
|
||||||
|
|
||||||
var user = new User
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Username = normalizedUsername,
|
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
|
||||||
DisplayName = request.DisplayName?.Trim(),
|
|
||||||
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
|
|
||||||
};
|
|
||||||
|
|
||||||
_db.Users.Add(user);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
|
|
||||||
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
_db.RefreshTokens.Add(new RefreshToken
|
_db.RefreshTokens.Add(new RefreshToken
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||||
UserId = user.Id,
|
UserId = profile.Id,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
});
|
});
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, profile.Username, profile.DisplayName, profile.NicknameColor));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
var result = await _userService.AuthenticateUserAsync(request.Username, request.Password);
|
||||||
return BadRequest(new ErrorResponse("Username and password are required."));
|
if (!result.IsSuccess)
|
||||||
|
return MapUserError(result);
|
||||||
|
|
||||||
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
var profile = result.User!;
|
||||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(profile);
|
||||||
|
|
||||||
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
|
||||||
return Unauthorized(new ErrorResponse("Invalid username or password."));
|
|
||||||
|
|
||||||
if (user.IsBanned)
|
|
||||||
return Unauthorized(new ErrorResponse("Your account has been banned."));
|
|
||||||
|
|
||||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(user);
|
|
||||||
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||||
|
|
||||||
_db.RefreshTokens.Add(new RefreshToken
|
_db.RefreshTokens.Add(new RefreshToken
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||||
UserId = user.Id,
|
UserId = profile.Id,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||||
});
|
});
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
|
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, profile.Username, profile.DisplayName, profile.NicknameColor));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
@@ -159,4 +124,14 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IActionResult MapUserError(UserOperationResult result) => result.Error switch
|
||||||
|
{
|
||||||
|
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
|
||||||
|
UserError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)),
|
||||||
|
UserError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)),
|
||||||
|
UserError.InvalidCredentials => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
|
||||||
|
UserError.Banned => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
|
||||||
|
_ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Server.Data;
|
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace EchoHub.Server.Controllers;
|
namespace EchoHub.Server.Controllers;
|
||||||
|
|
||||||
@@ -16,25 +15,24 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[EnableRateLimiting("general")]
|
[EnableRateLimiting("general")]
|
||||||
public class UsersController : ControllerBase
|
public class UsersController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly EchoHubDbContext _db;
|
private readonly IUserService _userService;
|
||||||
private readonly ImageToAsciiService _asciiService;
|
private readonly ImageToAsciiService _asciiService;
|
||||||
|
|
||||||
public UsersController(EchoHubDbContext db, ImageToAsciiService asciiService)
|
public UsersController(IUserService userService, ImageToAsciiService asciiService)
|
||||||
{
|
{
|
||||||
_db = db;
|
_userService = userService;
|
||||||
_asciiService = asciiService;
|
_asciiService = asciiService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{username}/profile")]
|
[HttpGet("{username}/profile")]
|
||||||
public async Task<IActionResult> GetProfile(string username)
|
public async Task<IActionResult> GetProfile(string username)
|
||||||
{
|
{
|
||||||
var normalizedUsername = username.ToLowerInvariant().Trim();
|
var profile = await _userService.GetUserProfileAsync(username);
|
||||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
|
||||||
|
|
||||||
if (user is null)
|
if (profile is null)
|
||||||
return NotFound(new ErrorResponse("User not found."));
|
return NotFound(new ErrorResponse("User not found."));
|
||||||
|
|
||||||
return Ok(ToProfileDto(user));
|
return Ok(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("profile")]
|
[HttpPut("profile")]
|
||||||
@@ -44,37 +42,13 @@ public class UsersController : ControllerBase
|
|||||||
if (userIdClaim is null)
|
if (userIdClaim is null)
|
||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var userId = Guid.Parse(userIdClaim);
|
var result = await _userService.UpdateProfileAsync(
|
||||||
var user = await _db.Users.FindAsync(userId);
|
Guid.Parse(userIdClaim), request.DisplayName, request.Bio, request.NicknameColor);
|
||||||
|
|
||||||
if (user is null)
|
if (!result.IsSuccess)
|
||||||
return NotFound(new ErrorResponse("User not found."));
|
return MapUserError(result);
|
||||||
|
|
||||||
if (request.DisplayName is not null)
|
return Ok(result.User!);
|
||||||
{
|
|
||||||
if (request.DisplayName.Length > ValidationConstants.MaxDisplayNameLength)
|
|
||||||
return BadRequest(new ErrorResponse($"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters."));
|
|
||||||
user.DisplayName = request.DisplayName.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.Bio is not null)
|
|
||||||
{
|
|
||||||
if (request.Bio.Length > ValidationConstants.MaxBioLength)
|
|
||||||
return BadRequest(new ErrorResponse($"Bio must not exceed {ValidationConstants.MaxBioLength} characters."));
|
|
||||||
user.Bio = request.Bio.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.NicknameColor is not null)
|
|
||||||
{
|
|
||||||
var color = request.NicknameColor.Trim();
|
|
||||||
if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color))
|
|
||||||
return BadRequest(new ErrorResponse("Nickname color must be a valid hex color (e.g. #FF5500)."));
|
|
||||||
user.NicknameColor = color.Length > 0 ? color : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return Ok(ToProfileDto(user));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("avatar")]
|
[HttpPost("avatar")]
|
||||||
@@ -85,12 +59,6 @@ public class UsersController : ControllerBase
|
|||||||
if (userIdClaim is null)
|
if (userIdClaim is null)
|
||||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||||
|
|
||||||
var userId = Guid.Parse(userIdClaim);
|
|
||||||
var user = await _db.Users.FindAsync(userId);
|
|
||||||
|
|
||||||
if (user is null)
|
|
||||||
return NotFound(new ErrorResponse("User not found."));
|
|
||||||
|
|
||||||
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
||||||
return BadRequest(new ErrorResponse("No file uploaded."));
|
return BadRequest(new ErrorResponse("No file uploaded."));
|
||||||
|
|
||||||
@@ -106,22 +74,20 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
var asciiArt = _asciiService.ConvertToAscii(stream);
|
var asciiArt = _asciiService.ConvertToAscii(stream);
|
||||||
|
|
||||||
user.AvatarAscii = asciiArt;
|
var result = await _userService.SetAvatarAsync(Guid.Parse(userIdClaim), asciiArt);
|
||||||
await _db.SaveChangesAsync();
|
if (!result.IsSuccess)
|
||||||
|
return MapUserError(result);
|
||||||
|
|
||||||
return Ok(new AvatarUploadResponse(asciiArt));
|
return Ok(new AvatarUploadResponse(asciiArt));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UserProfileDto ToProfileDto(Core.Models.User user) => new(
|
private IActionResult MapUserError(UserOperationResult result) => result.Error switch
|
||||||
user.Id,
|
{
|
||||||
user.Username,
|
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
|
||||||
user.DisplayName,
|
UserError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)),
|
||||||
user.Bio,
|
UserError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)),
|
||||||
user.NicknameColor,
|
UserError.InvalidCredentials => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
|
||||||
user.AvatarAscii,
|
UserError.Banned => Unauthorized(new ErrorResponse(result.ErrorMessage!)),
|
||||||
user.Status,
|
_ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")),
|
||||||
user.StatusMessage,
|
};
|
||||||
user.Role,
|
|
||||||
user.CreatedAt,
|
|
||||||
user.LastSeenAt);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy project files first for layer caching
|
||||||
|
COPY EchoHub.Core/EchoHub.Core.csproj EchoHub.Core/
|
||||||
|
COPY EchoHub.Server.Irc/EchoHub.Server.Irc.csproj EchoHub.Server.Irc/
|
||||||
|
COPY EchoHub.Server/EchoHub.Server.csproj EchoHub.Server/
|
||||||
|
COPY Directory.Build.props .
|
||||||
|
RUN dotnet restore EchoHub.Server/EchoHub.Server.csproj
|
||||||
|
|
||||||
|
# Copy everything and publish
|
||||||
|
COPY EchoHub.Core/ EchoHub.Core/
|
||||||
|
COPY EchoHub.Server.Irc/ EchoHub.Server.Irc/
|
||||||
|
COPY EchoHub.Server/ EchoHub.Server/
|
||||||
|
RUN dotnet publish EchoHub.Server/EchoHub.Server.csproj -c Release -o /app/publish --no-restore
|
||||||
|
|
||||||
|
# Runtime
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN groupadd -r echohub && useradd -r -g echohub -d /app echohub \
|
||||||
|
&& mkdir -p /app/data \
|
||||||
|
&& chown -R echohub:echohub /app
|
||||||
|
|
||||||
|
COPY --from=build --chown=echohub:echohub /app/publish .
|
||||||
|
COPY --chown=echohub:echohub EchoHub.Server/docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /app/docker-entrypoint.sh
|
||||||
|
|
||||||
|
USER echohub
|
||||||
|
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
Urls=http://0.0.0.0:5000 \
|
||||||
|
ConnectionStrings__DefaultConnection="Data Source=/app/data/echohub.db" \
|
||||||
|
Storage__Path=/app/data/uploads \
|
||||||
|
Serilog__WriteTo__1__Args__path=/app/data/logs/echohub-server-.log
|
||||||
|
|
||||||
|
EXPOSE 5000 6667 6697
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
@@ -56,7 +56,7 @@ public class ChatHub : Hub<IEchoHubClient>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<MessageDto>> JoinChannel(string channelName)
|
public async Task<JoinChannelResult> JoinChannel(string channelName)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -64,19 +64,15 @@ public class ChatHub : Hub<IEchoHubClient>
|
|||||||
Context.ConnectionId, CurrentUserId, CurrentUsername, channelName);
|
Context.ConnectionId, CurrentUserId, CurrentUsername, channelName);
|
||||||
|
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
{
|
return new JoinChannelResult(false, [], error);
|
||||||
await Clients.Caller.Error(error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim());
|
await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim());
|
||||||
return history;
|
return new JoinChannelResult(true, history);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error joining channel '{Channel}' for {User}", channelName, CurrentUsername);
|
_logger.LogError(ex, "Error joining channel '{Channel}' for {User}", channelName, CurrentUsername);
|
||||||
await Clients.Caller.Error($"Failed to join channel: {ex.Message}");
|
return new JoinChannelResult(false, [], $"Failed to join channel: {ex.Message}");
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,12 +109,14 @@ while (true)
|
|||||||
builder.Services.AddSingleton<LinkEmbedService>();
|
builder.Services.AddSingleton<LinkEmbedService>();
|
||||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||||
builder.Services.AddHostedService<FileCleanupService>();
|
builder.Services.AddHostedService<FileCleanupService>();
|
||||||
|
builder.Services.AddHostedService<MuteExpirationService>();
|
||||||
|
|
||||||
// ── Encryption ─────────────────────────────────────────────────────
|
// ── Encryption ─────────────────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
|
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
|
||||||
|
|
||||||
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
||||||
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
|
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
|
||||||
|
builder.Services.AddSingleton<IUserService, UserService>();
|
||||||
builder.Services.AddSingleton<IChannelService, ChannelService>();
|
builder.Services.AddSingleton<IChannelService, ChannelService>();
|
||||||
builder.Services.AddSingleton<IChatService, ChatService>();
|
builder.Services.AddSingleton<IChatService, ChatService>();
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,31 @@ public class ChatService : IChatService
|
|||||||
|
|
||||||
if (isNewJoin)
|
if (isNewJoin)
|
||||||
{
|
{
|
||||||
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId));
|
// Fetch presence data so clients can update their lists incrementally
|
||||||
|
UserPresenceDto? presence = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var presenceScope = _scopeFactory.CreateScope();
|
||||||
|
var presenceDb = presenceScope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
var user = await presenceDb.Users.FindAsync(userId);
|
||||||
|
if (user is not null)
|
||||||
|
{
|
||||||
|
presence = new UserPresenceDto(
|
||||||
|
user.Username, user.DisplayName, user.NicknameColor,
|
||||||
|
user.Status, user.StatusMessage, user.Role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to fetch presence for {User} on join", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't broadcast join for invisible users — they still get history but stay hidden
|
||||||
|
if (presence is null || presence.Status != UserStatus.Invisible)
|
||||||
|
{
|
||||||
|
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId));
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
|
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +296,7 @@ public class ChatService : IChatService
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
return await db.Users
|
return await db.Users
|
||||||
.Where(u => onlineUsernames.Contains(u.Username))
|
.Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible)
|
||||||
.Select(u => new UserPresenceDto(
|
.Select(u => new UserPresenceDto(
|
||||||
u.Username,
|
u.Username,
|
||||||
u.DisplayName,
|
u.DisplayName,
|
||||||
@@ -304,41 +328,9 @@ public class ChatService : IChatService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
|
||||||
{
|
|
||||||
username = username.ToLowerInvariant();
|
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
||||||
|
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
|
||||||
if (user is null) return null;
|
|
||||||
|
|
||||||
return new UserProfileDto(
|
|
||||||
user.Id, user.Username, user.DisplayName, user.Bio,
|
|
||||||
user.NicknameColor, user.AvatarAscii, user.Status,
|
|
||||||
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<string>> GetChannelsForUserAsync(string username)
|
public Task<List<string>> GetChannelsForUserAsync(string username)
|
||||||
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));
|
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));
|
||||||
|
|
||||||
public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password)
|
|
||||||
{
|
|
||||||
username = username.ToLowerInvariant();
|
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
||||||
|
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
|
||||||
if (user is null) return null;
|
|
||||||
|
|
||||||
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return (user.Id, user.Username);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Collapse consecutive newlines and cap total line count to prevent newline spam.
|
/// Collapse consecutive newlines and cap total line count to prevent newline spam.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -108,9 +108,47 @@ public partial class LinkEmbedService
|
|||||||
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
|
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
|
||||||
description = description is not null ? WebUtility.HtmlDecode(description) : null;
|
description = description is not null ? WebUtility.HtmlDecode(description) : null;
|
||||||
|
|
||||||
return new EmbedDto(siteName, title, description, null, url);
|
// Extract theme-color meta tag for embed border color
|
||||||
|
var themeColor = ParseThemeColor(html);
|
||||||
|
|
||||||
|
return new EmbedDto(siteName, title, description, null, url, themeColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? ParseThemeColor(string html)
|
||||||
|
{
|
||||||
|
// ThemeColorRegex: group 3 = color value
|
||||||
|
var match = ThemeColorRegex().Match(html);
|
||||||
|
var color = match.Success ? match.Groups[3].Value.Trim() : null;
|
||||||
|
|
||||||
|
if (color is null)
|
||||||
|
{
|
||||||
|
// ThemeColorReversedRegex: group 2 = color value
|
||||||
|
match = ThemeColorReversedRegex().Match(html);
|
||||||
|
color = match.Success ? match.Groups[2].Value.Trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (color.Length == 4 && color[0] == '#'
|
||||||
|
&& IsHexDigit(color[1]) && IsHexDigit(color[2]) && IsHexDigit(color[3]))
|
||||||
|
{
|
||||||
|
// Expand #RGB to #RRGGBB
|
||||||
|
return $"#{color[1]}{color[1]}{color[2]}{color[2]}{color[3]}{color[3]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.Length == 7 && color[0] == '#'
|
||||||
|
&& color[1..].All(IsHexDigit))
|
||||||
|
{
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsHexDigit(char c) =>
|
||||||
|
c is (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F');
|
||||||
|
|
||||||
private static List<string> ExtractUrls(string content)
|
private static List<string> ExtractUrls(string content)
|
||||||
{
|
{
|
||||||
var urls = new List<string>();
|
var urls = new List<string>();
|
||||||
@@ -213,4 +251,14 @@ public partial class LinkEmbedService
|
|||||||
|
|
||||||
[GeneratedRegex(@"<title[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
[GeneratedRegex(@"<title[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
private static partial Regex TitleTagRegex();
|
private static partial Regex TitleTagRegex();
|
||||||
|
|
||||||
|
// <meta name="theme-color" content="#hex">
|
||||||
|
[GeneratedRegex(@"<meta\s+[^>]*?name\s*=\s*([""'])theme-color\1[^>]*?content\s*=\s*([""'])(.*?)\2[^>]*/?>",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex ThemeColorRegex();
|
||||||
|
|
||||||
|
// <meta content="#hex" name="theme-color">
|
||||||
|
[GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*([""'])(.*?)\1[^>]*?name\s*=\s*([""'])theme-color\3[^>]*/?>",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex ThemeColorReversedRegex();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that periodically unmutes users whose timed mute has expired.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MuteExpirationService : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<MuteExpirationService> _logger;
|
||||||
|
|
||||||
|
public MuteExpirationService(IServiceScopeFactory scopeFactory, ILogger<MuteExpirationService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await UnmuteExpiredUsersAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error checking mute expirations");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(CheckInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UnmuteExpiredUsersAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var expired = await db.Users
|
||||||
|
.Where(u => u.IsMuted && u.MutedUntil.HasValue && u.MutedUntil.Value <= now)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (expired.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var user in expired)
|
||||||
|
{
|
||||||
|
user.IsMuted = false;
|
||||||
|
user.MutedUntil = null;
|
||||||
|
_logger.LogInformation("Auto-unmuted user {Username} (timed mute expired)", user.Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ public class PresenceTracker
|
|||||||
{
|
{
|
||||||
_connections[connectionId] = (userId, username);
|
_connections[connectionId] = (userId, username);
|
||||||
|
|
||||||
|
// Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside.
|
||||||
|
// It also makes the TryGetValue → add sequence atomic to prevent race conditions.
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
if (!_userConnections.TryGetValue(username, out var connections))
|
if (!_userConnections.TryGetValue(username, out var connections))
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
|
|
||||||
var currentCount = _presenceTracker.GetOnlineUserCount();
|
var currentCount = _presenceTracker.GetOnlineUserCount();
|
||||||
|
|
||||||
|
if (currentCount == _lastReportedUserCount)
|
||||||
|
continue;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
|
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ public class SignalRBroadcaster : IChatBroadcaster
|
|||||||
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
||||||
=> HubContext.Clients.Group(channelName).ReceiveMessage(message);
|
=> HubContext.Clients.Group(channelName).ReceiveMessage(message);
|
||||||
|
|
||||||
public Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
|
public Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
|
||||||
{
|
{
|
||||||
if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-"))
|
if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-"))
|
||||||
return HubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username);
|
return HubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username, presence);
|
||||||
|
|
||||||
return HubContext.Clients.Group(channelName).UserJoined(channelName, username);
|
return HubContext.Clients.Group(channelName).UserJoined(channelName, username, presence);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendUserLeftAsync(string channelName, string username)
|
public Task SendUserLeftAsync(string channelName, string username)
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
public class UserService : IUserService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
|
||||||
|
public UserService(IServiceScopeFactory scopeFactory)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
|
return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required.");
|
||||||
|
|
||||||
|
if (!ValidationConstants.UsernameRegex().IsMatch(username))
|
||||||
|
return UserOperationResult.Fail(UserError.ValidationFailed,
|
||||||
|
"Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens.");
|
||||||
|
|
||||||
|
if (password.Length < 6)
|
||||||
|
return UserOperationResult.Fail(UserError.ValidationFailed, "Password must be at least 6 characters.");
|
||||||
|
|
||||||
|
if (password.Length > ValidationConstants.MaxPasswordLength)
|
||||||
|
return UserOperationResult.Fail(UserError.ValidationFailed,
|
||||||
|
$"Password must not exceed {ValidationConstants.MaxPasswordLength} characters.");
|
||||||
|
|
||||||
|
var normalizedUsername = username.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
||||||
|
return UserOperationResult.Fail(UserError.AlreadyExists, "Username is already taken.");
|
||||||
|
|
||||||
|
var isFirstUser = !await db.Users.AnyAsync();
|
||||||
|
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Username = normalizedUsername,
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
||||||
|
DisplayName = displayName?.Trim(),
|
||||||
|
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Users.Add(user);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return UserOperationResult.Success(ToProfileDto(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserOperationResult> AuthenticateUserAsync(string username, string password)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
|
return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required.");
|
||||||
|
|
||||||
|
var normalizedUsername = username.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
||||||
|
|
||||||
|
if (user is null || !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
|
||||||
|
return UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password.");
|
||||||
|
|
||||||
|
if (user.IsBanned)
|
||||||
|
return UserOperationResult.Fail(UserError.Banned, "Your account has been banned.");
|
||||||
|
|
||||||
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return UserOperationResult.Success(ToProfileDto(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
||||||
|
{
|
||||||
|
var normalizedUsername = username.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
||||||
|
return user is null ? null : ToProfileDto(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileDto?> GetUserByIdAsync(Guid userId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var user = await db.Users.FindAsync(userId);
|
||||||
|
return user is null ? null : ToProfileDto(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserOperationResult> UpdateProfileAsync(
|
||||||
|
Guid userId, string? displayName, string? bio, string? nicknameColor)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var user = await db.Users.FindAsync(userId);
|
||||||
|
if (user is null)
|
||||||
|
return UserOperationResult.Fail(UserError.NotFound, "User not found.");
|
||||||
|
|
||||||
|
if (displayName is not null)
|
||||||
|
{
|
||||||
|
if (displayName.Length > ValidationConstants.MaxDisplayNameLength)
|
||||||
|
return UserOperationResult.Fail(UserError.ValidationFailed,
|
||||||
|
$"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters.");
|
||||||
|
user.DisplayName = displayName.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bio is not null)
|
||||||
|
{
|
||||||
|
if (bio.Length > ValidationConstants.MaxBioLength)
|
||||||
|
return UserOperationResult.Fail(UserError.ValidationFailed,
|
||||||
|
$"Bio must not exceed {ValidationConstants.MaxBioLength} characters.");
|
||||||
|
user.Bio = bio.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nicknameColor is not null)
|
||||||
|
{
|
||||||
|
var color = nicknameColor.Trim();
|
||||||
|
if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color))
|
||||||
|
return UserOperationResult.Fail(UserError.ValidationFailed,
|
||||||
|
"Nickname color must be a valid hex color (e.g. #FF5500).");
|
||||||
|
user.NicknameColor = color.Length > 0 ? color : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return UserOperationResult.Success(ToProfileDto(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserOperationResult> SetAvatarAsync(Guid userId, string asciiArt)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var user = await db.Users.FindAsync(userId);
|
||||||
|
if (user is null)
|
||||||
|
return UserOperationResult.Fail(UserError.NotFound, "User not found.");
|
||||||
|
|
||||||
|
user.AvatarAscii = asciiArt;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return UserOperationResult.Success(ToProfileDto(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserProfileDto ToProfileDto(User user) => new(
|
||||||
|
user.Id,
|
||||||
|
user.Username,
|
||||||
|
user.DisplayName,
|
||||||
|
user.Bio,
|
||||||
|
user.NicknameColor,
|
||||||
|
user.AvatarAscii,
|
||||||
|
user.Status,
|
||||||
|
user.StatusMessage,
|
||||||
|
user.Role,
|
||||||
|
user.CreatedAt,
|
||||||
|
user.LastSeenAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Persist appsettings.json in the data volume so auto-generated keys
|
||||||
|
# (JWT secret, encryption key) survive container recreation.
|
||||||
|
if [ ! -f /app/data/appsettings.json ]; then
|
||||||
|
cp /app/appsettings.example.json /app/data/appsettings.json
|
||||||
|
fi
|
||||||
|
ln -sf /app/data/appsettings.json /app/appsettings.json
|
||||||
|
|
||||||
|
exec dotnet EchoHub.Server.dll
|
||||||
@@ -132,7 +132,7 @@ public class IrcBroadcasterTests
|
|||||||
{
|
{
|
||||||
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
await _broadcaster.SendUserJoinedAsync("general", "alice");
|
await _broadcaster.SendUserJoinedAsync("general", "alice", null);
|
||||||
|
|
||||||
var output = bobStream.GetOutputLines();
|
var output = bobStream.GetOutputLines();
|
||||||
Assert.Contains(output, l => l.Contains("JOIN #general") && l.Contains("alice"));
|
Assert.Contains(output, l => l.Contains("JOIN #general") && l.Contains("alice"));
|
||||||
@@ -144,7 +144,7 @@ public class IrcBroadcasterTests
|
|||||||
var (conn, excludedStream) = AddConnectionWithCapture("alice", "general");
|
var (conn, excludedStream) = AddConnectionWithCapture("alice", "general");
|
||||||
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
await _broadcaster.SendUserJoinedAsync("general", "alice", conn.ConnectionId);
|
await _broadcaster.SendUserJoinedAsync("general", "alice", null, conn.ConnectionId);
|
||||||
|
|
||||||
// Excluded connection should not get the message
|
// Excluded connection should not get the message
|
||||||
Assert.Empty(excludedStream.GetOutputLines());
|
Assert.Empty(excludedStream.GetOutputLines());
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ public class IrcCommandHandlerTests
|
|||||||
{
|
{
|
||||||
private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null };
|
private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null };
|
||||||
private readonly FakeChatService _chatService = new();
|
private readonly FakeChatService _chatService = new();
|
||||||
|
private readonly FakeUserService _userService = new();
|
||||||
private readonly FakeChannelService _channelService = new();
|
private readonly FakeChannelService _channelService = new();
|
||||||
private readonly FakeEncryptionService _encryption = new();
|
private readonly FakeEncryptionService _encryption = new();
|
||||||
|
|
||||||
private IrcCommandHandler CreateHandler(IrcClientConnection conn) =>
|
private IrcCommandHandler CreateHandler(IrcClientConnection conn) =>
|
||||||
new(conn, _options, _chatService, _channelService, _encryption, NullLogger.Instance);
|
new(conn, _options, _chatService, _userService, _channelService, _encryption, NullLogger.Instance);
|
||||||
|
|
||||||
private async Task<List<string>> RunAndCapture(string[] inputLines,
|
private async Task<List<string>> RunAndCapture(string[] inputLines,
|
||||||
Action<IrcClientConnection>? setup = null)
|
Action<IrcClientConnection>? setup = null)
|
||||||
@@ -85,7 +86,7 @@ public class IrcCommandHandlerTests
|
|||||||
public async Task PassNickUser_ValidCredentials_Registers()
|
public async Task PassNickUser_ValidCredentials_Registers()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
_chatService.AuthResult = (userId, "alice");
|
_userService.AuthResult = FakeUserService.SuccessResult(userId, "alice");
|
||||||
|
|
||||||
var lines = await RunAndCapture([
|
var lines = await RunAndCapture([
|
||||||
"PASS secret123",
|
"PASS secret123",
|
||||||
@@ -112,7 +113,7 @@ public class IrcCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PassNickUser_WrongPassword_GetsAuthError()
|
public async Task PassNickUser_WrongPassword_GetsAuthError()
|
||||||
{
|
{
|
||||||
_chatService.AuthResult = null;
|
_userService.AuthResult = null;
|
||||||
|
|
||||||
var lines = await RunAndCapture([
|
var lines = await RunAndCapture([
|
||||||
"PASS wrongpassword",
|
"PASS wrongpassword",
|
||||||
@@ -120,7 +121,8 @@ public class IrcCommandHandlerTests
|
|||||||
"USER alice 0 * :Alice Smith"
|
"USER alice 0 * :Alice Smith"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Assert.Contains(lines, l => l.Contains("464") && l.Contains("incorrect"));
|
Assert.Contains(lines, l => l.Contains("464"));
|
||||||
|
Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Authentication failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -189,7 +191,7 @@ public class IrcCommandHandlerTests
|
|||||||
public async Task SaslPlain_ValidCredentials_Authenticates()
|
public async Task SaslPlain_ValidCredentials_Authenticates()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
var userId = Guid.NewGuid();
|
||||||
_chatService.AuthResult = (userId, "alice");
|
_userService.AuthResult = FakeUserService.SuccessResult(userId, "alice");
|
||||||
|
|
||||||
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0password123"));
|
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0password123"));
|
||||||
|
|
||||||
@@ -210,7 +212,7 @@ public class IrcCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SaslPlain_InvalidCredentials_GetsError()
|
public async Task SaslPlain_InvalidCredentials_GetsError()
|
||||||
{
|
{
|
||||||
_chatService.AuthResult = null;
|
_userService.AuthResult = null;
|
||||||
|
|
||||||
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0wrongpwd"));
|
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0wrongpwd"));
|
||||||
|
|
||||||
@@ -478,7 +480,7 @@ public class IrcCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Whois_ExistingUser_ReturnsInfo()
|
public async Task Whois_ExistingUser_ReturnsInfo()
|
||||||
{
|
{
|
||||||
_chatService.ProfileToReturn = new UserProfileDto(
|
_userService.ProfileToReturn = new UserProfileDto(
|
||||||
Guid.NewGuid(), "bob", "Bob S.", "Hello!", null, null,
|
Guid.NewGuid(), "bob", "Bob S.", "Hello!", null, null,
|
||||||
UserStatus.Online, null, ServerRole.Member,
|
UserStatus.Online, null, ServerRole.Member,
|
||||||
DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow);
|
DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow);
|
||||||
@@ -496,7 +498,7 @@ public class IrcCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Whois_NonexistentUser_GetsNoSuchNickError()
|
public async Task Whois_NonexistentUser_GetsNoSuchNickError()
|
||||||
{
|
{
|
||||||
_chatService.ProfileToReturn = null;
|
_userService.ProfileToReturn = null;
|
||||||
|
|
||||||
var lines = await RunAuthenticated(["WHOIS ghost"]);
|
var lines = await RunAuthenticated(["WHOIS ghost"]);
|
||||||
|
|
||||||
@@ -506,7 +508,7 @@ public class IrcCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Whois_AwayUser_ShowsAwayMessage()
|
public async Task Whois_AwayUser_ShowsAwayMessage()
|
||||||
{
|
{
|
||||||
_chatService.ProfileToReturn = new UserProfileDto(
|
_userService.ProfileToReturn = new UserProfileDto(
|
||||||
Guid.NewGuid(), "bob", null, null, null, null,
|
Guid.NewGuid(), "bob", null, null, null, null,
|
||||||
UserStatus.Away, "Gone fishing", ServerRole.Member,
|
UserStatus.Away, "Gone fishing", ServerRole.Member,
|
||||||
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow);
|
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow);
|
||||||
|
|||||||
@@ -156,8 +156,6 @@ internal sealed class FakeChatService : IChatService
|
|||||||
public List<MessageDto> HistoryToReturn { get; set; } = [];
|
public List<MessageDto> HistoryToReturn { get; set; } = [];
|
||||||
public string? JoinError { get; set; }
|
public string? JoinError { get; set; }
|
||||||
public string? SendMessageError { get; set; }
|
public string? SendMessageError { get; set; }
|
||||||
public (Guid UserId, string Username)? AuthResult { get; set; }
|
|
||||||
public UserProfileDto? ProfileToReturn { get; set; }
|
|
||||||
public List<string> ChannelsForUserToReturn { get; set; } = [];
|
public List<string> ChannelsForUserToReturn { get; set; } = [];
|
||||||
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
|
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
|
||||||
|
|
||||||
@@ -210,14 +208,8 @@ internal sealed class FakeChatService : IChatService
|
|||||||
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) =>
|
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
|
|
||||||
Task.FromResult(ProfileToReturn);
|
|
||||||
|
|
||||||
public Task<List<string>> GetChannelsForUserAsync(string username) =>
|
public Task<List<string>> GetChannelsForUserAsync(string username) =>
|
||||||
Task.FromResult(ChannelsForUserToReturn);
|
Task.FromResult(ChannelsForUserToReturn);
|
||||||
|
|
||||||
public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) =>
|
|
||||||
Task.FromResult(AuthResult);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -258,3 +250,43 @@ internal sealed class FakeChannelService : IChannelService
|
|||||||
public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
|
public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
|
||||||
Task.FromResult(MembershipResult);
|
Task.FromResult(MembershipResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fake user service that records method calls and returns pre-configured results.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FakeUserService : IUserService
|
||||||
|
{
|
||||||
|
// Configurable results
|
||||||
|
public UserOperationResult? AuthResult { get; set; }
|
||||||
|
public UserOperationResult? RegisterResult { get; set; }
|
||||||
|
public UserProfileDto? ProfileToReturn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to create a success result from a simple userId + username pair.
|
||||||
|
/// </summary>
|
||||||
|
public static UserOperationResult SuccessResult(Guid userId, string username) =>
|
||||||
|
UserOperationResult.Success(new UserProfileDto(
|
||||||
|
userId, username, null, null, null, null,
|
||||||
|
UserStatus.Online, null, ServerRole.Member,
|
||||||
|
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
public Task<UserOperationResult> AuthenticateUserAsync(string username, string password) =>
|
||||||
|
Task.FromResult(AuthResult
|
||||||
|
?? UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password."));
|
||||||
|
|
||||||
|
public Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null) =>
|
||||||
|
Task.FromResult(RegisterResult
|
||||||
|
?? UserOperationResult.Fail(UserError.AlreadyExists, "Username is already taken."));
|
||||||
|
|
||||||
|
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
|
||||||
|
Task.FromResult(ProfileToReturn);
|
||||||
|
|
||||||
|
public Task<UserProfileDto?> GetUserByIdAsync(Guid userId) =>
|
||||||
|
Task.FromResult(ProfileToReturn);
|
||||||
|
|
||||||
|
public Task<UserOperationResult> UpdateProfileAsync(Guid userId, string? displayName, string? bio, string? nicknameColor) =>
|
||||||
|
Task.FromResult(UserOperationResult.Fail(UserError.NotFound, "Not configured"));
|
||||||
|
|
||||||
|
public Task<UserOperationResult> SetAvatarAsync(Guid userId, string asciiArt) =>
|
||||||
|
Task.FromResult(UserOperationResult.Fail(UserError.NotFound, "Not configured"));
|
||||||
|
}
|
||||||
|
|||||||
Submodule src/Terminal.Gui deleted from 0061d03558
Reference in New Issue
Block a user