mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 23:34:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce41ef9c0a | ||
|
|
9aea6ecfc3 | ||
|
|
5040c5c201 | ||
|
|
a3a413f0b1 | ||
|
|
6292e82cec | ||
|
|
2d33773c24 | ||
|
|
e05b420ce9 | ||
|
|
ea8e583ee5 | ||
|
|
3ca9dbfd91 | ||
|
|
15187c4665 | ||
|
|
64bca51619 | ||
|
|
3538ec8005 | ||
|
|
1b234e39e1 | ||
|
|
19bf123c8c | ||
|
|
6cf284a475 | ||
|
|
3b45bb5661 | ||
|
|
6235565480 | ||
|
|
ecb20c4c52 | ||
|
|
a85d16fff8 | ||
|
|
6db93ecfea | ||
|
|
67587dafc2 | ||
|
|
1bbe099835 | ||
|
|
769aa5b468 | ||
|
|
6660944588 | ||
|
|
4b41438af0 | ||
|
|
a4fe432992 | ||
|
|
9279e8be06 | ||
|
|
7f9fcfe3cc | ||
|
|
62c5ab27c5 | ||
|
|
240892495b | ||
|
|
6e7cbf39f0 | ||
|
|
83d257591e | ||
|
|
e42f1a0965 | ||
|
|
fe6dfd3d4d | ||
|
|
d6282885e3 | ||
|
|
bd8b88add2 | ||
|
|
ff9a9e3dd0 | ||
|
|
aa6599a4e0 | ||
|
|
3091a146eb | ||
|
|
000764fdb4 | ||
|
|
56fccf5cfb | ||
|
|
b98673f8c5 | ||
|
|
335cfcc28a | ||
|
|
6aef6890cf | ||
|
|
5b8df9d505 | ||
|
|
6dbc29818c | ||
|
|
6758aceb5d | ||
|
|
06aba16303 | ||
|
|
89b028cf06 | ||
|
|
eb4aac9861 | ||
|
|
38d63d844d | ||
|
|
3f2211f42a | ||
|
|
0606394af2 | ||
|
|
ee18743721 | ||
|
|
0aaa371488 | ||
|
|
8689dc2a01 | ||
|
|
5d61266fd7 | ||
|
|
ae342381e0 | ||
|
|
ba536c6f9c | ||
|
|
92b89fa8a0 | ||
|
|
9029d63e54 | ||
|
|
023e62dea6 | ||
|
|
36e1ea0dfa | ||
|
|
be18cf88df | ||
|
|
595475c436 | ||
|
|
1b6c27247f | ||
|
|
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,44 @@
|
||||
# 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
|
||||
# Hostnames advertised to the EchoHubSpace directory. Index per entry.
|
||||
# Server__PublicHosts__0=echohub.example.com
|
||||
# Server__PublicHosts__1=alias.example.com
|
||||
# Topic tags surfaced in the EchoHubSpace browser. Index per entry.
|
||||
# Server__Tags__0=community
|
||||
# Server__Tags__1=gaming
|
||||
# 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
|
||||
steps:
|
||||
- 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
|
||||
uses: actions/setup-dotnet@v4
|
||||
@@ -35,7 +29,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- 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:
|
||||
name: Build & Test
|
||||
@@ -44,11 +38,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
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
|
||||
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
|
||||
steps:
|
||||
- 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
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Release Checklist
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-checklist:
|
||||
name: Release Checklist
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error file=src/Directory.Build.props::Could not read version from Directory.Build.props"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Check version was bumped from master
|
||||
run: |
|
||||
BRANCH_VERSION="${{ steps.version.outputs.version }}"
|
||||
git fetch origin master --depth=1
|
||||
MASTER_VERSION=$(git show origin/master:src/Directory.Build.props | grep -oP '(?<=<Version>)[^<]+')
|
||||
echo "Branch: $BRANCH_VERSION | Master: $MASTER_VERSION"
|
||||
if [ "$BRANCH_VERSION" = "$MASTER_VERSION" ]; then
|
||||
echo "::error file=src/Directory.Build.props::Version $BRANCH_VERSION was not bumped from master. Update <Version> in src/Directory.Build.props."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check changelog file exists
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
FILE="docs/changelog/v${VERSION}.md"
|
||||
if [ ! -f "$FILE" ]; then
|
||||
echo "::error::Missing changelog file: $FILE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found: $FILE"
|
||||
|
||||
- name: Check changelog TOC
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
if ! grep -q "v${VERSION}.md" docs/changelog/toc.yml; then
|
||||
echo "::error file=docs/changelog/toc.yml::v${VERSION} not found in changelog TOC. Add it to docs/changelog/toc.yml."
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "v${VERSION}" docs/changelog/index.md; then
|
||||
echo "::error file=docs/changelog/index.md::v${VERSION} not found in changelog index. Add it to docs/changelog/index.md."
|
||||
exit 1
|
||||
fi
|
||||
echo "toc.yml and index.md: OK"
|
||||
@@ -12,15 +12,15 @@ jobs:
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_release: ${{ steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' }}
|
||||
release_exists: ${{ steps.check_release.outputs.exists }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
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
|
||||
id: changes
|
||||
@@ -35,7 +35,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Read version
|
||||
if: steps.changes.outputs.src_changed == 'true'
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
|
||||
@@ -43,7 +42,6 @@ jobs:
|
||||
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if release exists
|
||||
if: steps.changes.outputs.src_changed == 'true'
|
||||
id: check_release
|
||||
run: |
|
||||
if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
|
||||
@@ -58,7 +56,7 @@ jobs:
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Publish Server win-x64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
@@ -76,21 +74,29 @@ jobs:
|
||||
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
|
||||
|
||||
- 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
|
||||
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 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-win-x64
|
||||
|
||||
- name: Publish Client linux-x64
|
||||
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-x64 --self-contained true -o publish/client-linux-x64
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-linux-x64
|
||||
|
||||
- name: Publish Client osx-x64
|
||||
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-x64 --self-contained true -o publish/client-osx-x64
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-osx-x64
|
||||
|
||||
- name: Publish Client osx-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 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 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=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 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-linux-arm64
|
||||
|
||||
- name: Zip artifacts
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
@@ -100,10 +106,12 @@ jobs:
|
||||
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-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-linux-x64.zip client-linux-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-linux-arm64.zip client-linux-arm64/
|
||||
|
||||
- name: Build release notes
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
@@ -144,9 +152,57 @@ jobs:
|
||||
EchoHub-Server-linux-x64.zip \
|
||||
EchoHub-Server-osx-x64.zip \
|
||||
EchoHub-Server-osx-arm64.zip \
|
||||
EchoHub-Server-linux-arm64.zip \
|
||||
EchoHub-Client-win-x64.zip \
|
||||
EchoHub-Client-linux-x64.zip \
|
||||
EchoHub-Client-osx-x64.zip \
|
||||
EchoHub-Client-osx-arm64.zip
|
||||
EchoHub-Client-osx-arm64.zip \
|
||||
EchoHub-Client-linux-arm64.zip
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
choco:
|
||||
name: Publish to Chocolatey
|
||||
needs: release
|
||||
if: needs.release.outputs.release_exists == 'true' || needs.release.outputs.should_release == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check if version already published
|
||||
id: choco_check
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.release.outputs.version }}"
|
||||
$result = choco search echohub --version $version --exact --source https://community.chocolatey.org/api/v2/ 2>&1
|
||||
if ($result -match "echohub $version") {
|
||||
echo "exists=true" >> $env:GITHUB_OUTPUT
|
||||
Write-Host "Chocolatey package echohub $version already published — skipping."
|
||||
} else {
|
||||
echo "exists=false" >> $env:GITHUB_OUTPUT
|
||||
}
|
||||
|
||||
- name: Download release asset
|
||||
if: steps.choco_check.outputs.exists == 'false'
|
||||
run: gh release download "v${{ needs.release.outputs.version }}" --pattern "EchoHub-Client-win-x64.zip"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Pack and push
|
||||
if: steps.choco_check.outputs.exists == 'false'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.release.outputs.version }}"
|
||||
$checksum = (Get-FileHash EchoHub-Client-win-x64.zip -Algorithm SHA256).Hash.ToLower()
|
||||
|
||||
# Stamp version and checksum into package templates
|
||||
(Get-Content packaging/choco/echohub.nuspec) -replace '__VERSION__', $version | Set-Content packaging/choco/echohub.nuspec
|
||||
(Get-Content packaging/choco/tools/chocolateyInstall.ps1) -replace '__VERSION__', $version | Set-Content packaging/choco/tools/chocolateyInstall.ps1
|
||||
(Get-Content packaging/choco/tools/chocolateyInstall.ps1) -replace '__CHECKSUM64__', $checksum | Set-Content packaging/choco/tools/chocolateyInstall.ps1
|
||||
|
||||
cd packaging/choco
|
||||
choco pack echohub.nuspec --output-directory $env:TEMP
|
||||
$pkg = Get-ChildItem "$env:TEMP\echohub.*.nupkg" | Select-Object -First 1
|
||||
choco push $pkg.FullName --source https://push.chocolatey.org/ --api-key $env: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)
|
||||
"MD033": false,
|
||||
// Allow bare URLs
|
||||
"MD034": false
|
||||
"MD034": false,
|
||||
// Allow compact table pipe style (flow docs use compact tables)
|
||||
"MD060": false
|
||||
},
|
||||
|
||||
"globs": ["**/*.md"],
|
||||
|
||||
@@ -27,12 +27,14 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/HueByte/EchoHub/actions/workflows/ci.yml"><img alt="Build" src="https://img.shields.io/github/actions/workflow/status/HueByte/EchoHub/ci.yml?branch=master&style=flat-square&logo=github&label=Build" /></a>
|
||||
<a href="https://github.com/HueByte/EchoHub/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/HueByte/EchoHub?style=flat-square&logo=github&label=Release" /></a>
|
||||
<a href="https://community.chocolatey.org/packages/echohub"><img alt="Chocolatey" src="https://img.shields.io/chocolatey/v/echohub?style=flat-square&logo=chocolatey&label=Chocolatey" /></a>
|
||||
<a href="https://github.com/HueByte/EchoHub/pkgs/container/echohub-server"><img alt="Docker" src="https://img.shields.io/badge/Docker-GHCR-2496ED?style=flat-square&logo=docker&logoColor=white" /></a>
|
||||
<img alt=".NET 10" src="https://img.shields.io/badge/.NET-10-512BD4?style=flat-square&logo=dotnet&logoColor=white" />
|
||||
<img alt="SignalR" src="https://img.shields.io/badge/SignalR-Real--time-0078D4?style=flat-square" />
|
||||
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-EF%20Core-003B57?style=flat-square&logo=sqlite&logoColor=white" />
|
||||
<img alt="License" src="https://img.shields.io/badge/License-MIT-green?style=flat-square" />
|
||||
<img alt="Terminal.Gui" src="https://img.shields.io/badge/TUI-Terminal.Gui%20v2-yellow?style=flat-square" />
|
||||
<img alt="Electron" src="https://img.shields.io/badge/Electron-None-red?style=flat-square" />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/github/license/HueByte/EchoHub?style=flat-square" /></a>
|
||||
<img alt="Repo size" src="https://img.shields.io/github/repo-size/HueByte/EchoHub?style=flat-square&label=Size" />
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -76,10 +78,12 @@ graph TD
|
||||
### Server
|
||||
|
||||
- **Self-hostable** — your server, your rules, your data
|
||||
- **Docker ready** — `docker compose up -d` and you're done
|
||||
- **Real-time messaging** via SignalR WebSockets
|
||||
- **IRC gateway** — native IRC clients connect alongside TUI users, full cross-protocol messaging
|
||||
- **JWT auth** with short-lived access tokens and 30-day refresh tokens
|
||||
- **Channels** — create, set topics, delete (no 47-step permission wizard required)
|
||||
- **Moderation** — ban, mute (timed or permanent), kick, role assignment
|
||||
- **File & image uploads** with actual validation (magic bytes, not just trusting the extension)
|
||||
- **Image-to-ASCII** — because images in a terminal is objectively cool
|
||||
- **Presence tracking** — online/away/DND/invisible with custom status messages
|
||||
@@ -91,37 +95,59 @@ graph TD
|
||||
### Client
|
||||
|
||||
- **Runs in your terminal** — no browser, no Electron, no 500MB of bundled Chromium
|
||||
- **13 built-in themes** — including `hacker` for when you want to feel like you're in a movie
|
||||
- **14 built-in themes** — including `hacker` for when you want to feel like you're in a movie
|
||||
- **Slash commands** — `/join`, `/send`, `/status`, `/theme`, etc.
|
||||
- **Colored nicknames** — pick your hex color, express yourself
|
||||
- **File/image sharing** — local files or URLs
|
||||
- **Clickable everything** — usernames, @mentions, #channels — just press Enter
|
||||
- **File/image sharing** — local files or URLs; drag & drop a file onto the terminal to send it; save the original behind any ASCII-art image
|
||||
- **End-to-end encrypted rooms** — password-protected channels are encrypted with a passphrase-derived key that never reaches the server, so not even the server owner can read messages or files (they can still see counts and storage size)
|
||||
- **Multi-server** — save and switch between servers
|
||||
- **Auto-reconnect** — drops happen, it rejoins your channels automatically
|
||||
- **Auto-updater** — updates in-place with automatic rollback if something goes wrong
|
||||
- **Message history** on join — you won't miss context
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Download
|
||||
### Install the Client
|
||||
|
||||
Grab a self-contained binary from [Releases](../../releases) — no runtime needed, just run it.
|
||||
**Windows (Chocolatey):**
|
||||
|
||||
### Prerequisites (for development)
|
||||
```bash
|
||||
choco install echohub
|
||||
```
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
|
||||
**Linux / macOS:**
|
||||
|
||||
### Run the Server
|
||||
```bash
|
||||
curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
**Manual download:** grab a self-contained binary from [Releases](../../releases) — no runtime needed, just run it.
|
||||
|
||||
### Host a Server
|
||||
|
||||
**Docker (recommended):**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Pre-built images on [GHCR](https://github.com/HueByte/EchoHub/pkgs/container/echohub-server) — `linux/amd64` and `linux/arm64`.
|
||||
|
||||
**From source:**
|
||||
|
||||
```bash
|
||||
dotnet run --project src/EchoHub.Server
|
||||
```
|
||||
|
||||
First run does everything for you:
|
||||
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download). First run does everything for you:
|
||||
|
||||
1. Creates `appsettings.json` from the example config
|
||||
2. Generates a secure JWT secret
|
||||
3. Creates the database with a `#general` channel
|
||||
|
||||
### Run the Client
|
||||
### Run the Client (from source)
|
||||
|
||||
```bash
|
||||
dotnet run --project src/EchoHub.Client
|
||||
@@ -165,7 +191,7 @@ irssi -c your-server.com -p 6667 -w <password> -n <username>
|
||||
/connect echohub
|
||||
```
|
||||
|
||||
IRC users must have an existing EchoHub account (no registration via IRC). Auth works via `PASS`/`NICK`/`USER` or SASL PLAIN.
|
||||
Auth works via `PASS`/`NICK`/`USER` or SASL PLAIN. New usernames are auto-registered on first connect — no separate signup needed.
|
||||
|
||||
### What Works
|
||||
|
||||
@@ -199,7 +225,10 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
||||
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `/join <channel>` | Join a channel |
|
||||
| `/join <channel> [password]` | Join a channel (passphrase for encrypted channels) |
|
||||
| `/passwd <old> <new>` | Change the current encrypted channel's passphrase (creator only) |
|
||||
| `/size [s\|m\|l]` | ASCII-art size for attached images (no arg = picker) |
|
||||
| `/downloadpath [path]` | Set the download folder (no path = native folder picker) |
|
||||
| `/leave` | Leave current channel |
|
||||
| `/topic <text>` | Set channel topic (creator only) |
|
||||
| `/send <file or URL>` | Upload a file or image |
|
||||
@@ -214,6 +243,8 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
||||
| `/help` | Show help |
|
||||
| `/quit` | Exit |
|
||||
|
||||
**Message actions:** **right-click a message** for a context menu — delete, save/download/play its attachment, mention the sender, view their profile, or copy the text. (Keyboard alternative: press <kbd>F6</kbd> to focus the message list, select with the arrow keys, and press <kbd>Delete</kbd>; <kbd>F6</kbd> again returns to the input.) You can always delete your own messages; moderators and above can delete others' messages, but only from users below their own role.
|
||||
|
||||
## Themes
|
||||
|
||||
`/theme <name>` to switch:
|
||||
@@ -222,6 +253,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
||||
| ----- | ---- |
|
||||
| `default` | Gray on black — clean and quiet |
|
||||
| `transparent` | White on black — for fancy transparent terminals |
|
||||
| `transparentlight` | Black on transparent — dark characters for light transparent terminals |
|
||||
| `classic` | White on blue — IRC nostalgia |
|
||||
| `light` | Black on white — for the brave |
|
||||
| `hacker` | Green on black — *I'm in* |
|
||||
|
||||
@@ -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:
|
||||
@@ -52,7 +52,7 @@ Terminal.Gui v2 TUI application:
|
||||
|
||||
- **UI**: Main window, dialogs, chat renderer with ANSI color support
|
||||
- **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate)
|
||||
- **Themes**: 13 built-in color themes (including transparent theme with true terminal transparency)
|
||||
- **Themes**: 14 built-in color themes (including transparent dark/light themes with true terminal transparency)
|
||||
- **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens)
|
||||
|
||||
## Communication
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Configuration
|
||||
|
||||
EchoHub Server generates an `appsettings.json` with sensible defaults on first run (including a random JWT secret), so you can launch and start chatting immediately. Tweak things later when you feel like it.
|
||||
|
||||
> [!NOTE]
|
||||
> Under the hood, EchoHub Server is built on ASP.NET Core, so it inherits the standard .NET configuration system. If you're familiar with that, everything works exactly as you'd expect. If not — no worries, this page covers everything you need.
|
||||
|
||||
## How It Works
|
||||
|
||||
EchoHub Server loads settings from multiple sources. Each source **overrides** the previous one, so you can layer defaults with environment-specific values:
|
||||
|
||||
```text
|
||||
1. appsettings.json (base defaults)
|
||||
2. appsettings.{Environment}.json (e.g. appsettings.Production.json)
|
||||
3. Environment variables (great for Docker / CI)
|
||||
4. Command-line arguments (highest priority)
|
||||
```
|
||||
|
||||
The last one wins. If `appsettings.json` says `"Irc:Port": 6667` but you pass `--Irc:Port=7000` on the command line, port 7000 is what you get.
|
||||
|
||||
In practice this means you can leave `appsettings.json` alone and override just the settings you care about using environment variables or CLI flags — no need to edit JSON files if that's not your thing.
|
||||
|
||||
### Environment Variable Mapping
|
||||
|
||||
Environment variables use **double underscores** (`__`) in place of the JSON nesting. The rule is simple — replace every `:` (or each level of JSON nesting) with `__`:
|
||||
|
||||
| appsettings.json path | Environment variable |
|
||||
| --- | --- |
|
||||
| `Server:Name` | `Server__Name` |
|
||||
| `Irc:Enabled` | `Irc__Enabled` |
|
||||
| `Jwt:Secret` | `Jwt__Secret` |
|
||||
| `Serilog:MinimumLevel:Default` | `Serilog__MinimumLevel__Default` |
|
||||
| `ConnectionStrings:DefaultConnection` | `ConnectionStrings__DefaultConnection` |
|
||||
|
||||
Arrays use numeric indices: `Server:Admins:0` becomes `Server__Admins__0`, `Server:Admins:1` becomes `Server__Admins__1`, and so on.
|
||||
|
||||
This is why the Docker `.env` file uses `Server__Name=My Server` instead of JSON — Docker passes these as environment variables, and the server picks them up automatically.
|
||||
|
||||
### Examples
|
||||
|
||||
All three of these achieve the same thing — use whichever fits your setup.
|
||||
|
||||
**appsettings.json** (direct editing):
|
||||
|
||||
```json
|
||||
{
|
||||
"Server": {
|
||||
"Name": "My EchoHub Server",
|
||||
"PublicServer": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Environment variables** (Docker, systemd, shell export):
|
||||
|
||||
```bash
|
||||
export Server__Name="My EchoHub Server"
|
||||
export Server__PublicServer=true
|
||||
```
|
||||
|
||||
**Command-line arguments** (quick overrides, highest priority):
|
||||
|
||||
```bash
|
||||
./EchoHub.Server --Server:Name="My EchoHub Server" --Irc:Enabled=true
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
The full `appsettings.json` is auto-generated on first run from the [example config](https://github.com/HueByte/EchoHub/blob/master/src/EchoHub.Server/appsettings.example.json). Here's every option:
|
||||
|
||||
### General
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Urls` | `http://0.0.0.0:5000` | Listen address and port |
|
||||
| `AllowedHosts` | `*` | Allowed host headers (leave `*` unless you need host filtering) |
|
||||
|
||||
### Database
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `ConnectionStrings:DefaultConnection` | *(empty)* | SQLite connection string. Empty = `echohub.db` in the app directory |
|
||||
|
||||
### Authentication
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Jwt:Secret` | *(auto-generated)* | Signing key (min 32 chars). Auto-generated on first run |
|
||||
| `Jwt:Issuer` | `EchoHub.Server` | JWT issuer claim |
|
||||
| `Jwt:Audience` | `EchoHub.Client` | JWT audience claim |
|
||||
|
||||
Access tokens expire after 15 minutes, refresh tokens after 30 days with rotation on each use.
|
||||
|
||||
### Server Identity
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Server:Name` | `My EchoHub Server` | Display name shown to clients |
|
||||
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
|
||||
| `Server:PublicServer` | `false` | Register on the [public directory](https://echohub.voidcube.cloud/servers) |
|
||||
| `Server:PublicHost` | *(empty)* | Public hostname for the directory listing (e.g. `chat.example.com:5000`) |
|
||||
| `Server:Admins` | `[]` | Array of admin usernames (e.g. `["alice", "bob"]`) |
|
||||
|
||||
### Encryption
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Encryption:Key` | *(auto-generated)* | AES key for message encryption in transit |
|
||||
| `Encryption:EncryptDatabase` | `false` | Also encrypt message content at rest in SQLite |
|
||||
|
||||
### Storage
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Storage:CleanupIntervalHours` | `1` | How often the cleanup job runs (hours) |
|
||||
| `Storage:RetentionDays` | `30` | Days to keep uploaded files before cleanup |
|
||||
|
||||
### IRC Gateway
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Irc:Enabled` | `false` | Enable the IRC protocol gateway |
|
||||
| `Irc:Port` | `6667` | IRC plain-text listen port |
|
||||
| `Irc:TlsEnabled` | `false` | Enable TLS termination for IRC |
|
||||
| `Irc:TlsPort` | `6697` | IRC TLS listen port |
|
||||
| `Irc:TlsCertPath` | *(empty)* | Path to a PKCS#12 (`.pfx`) certificate |
|
||||
| `Irc:TlsCertPassword` | *(empty)* | Password for the certificate file |
|
||||
| `Irc:ServerName` | `echohub` | IRC server name in protocol messages |
|
||||
| `Irc:Motd` | `Welcome to EchoHub IRC Gateway!` | Message of the day |
|
||||
|
||||
### Logging
|
||||
|
||||
EchoHub uses [Serilog](https://serilog.net/) for structured logging — console output + daily rolling files with 14-day retention by default.
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Serilog:MinimumLevel:Default` | `Information` | Global log level (`Debug`, `Information`, `Warning`, `Error`) |
|
||||
| `Serilog:MinimumLevel:Override:Microsoft` | `Warning` | Suppress noisy framework logs |
|
||||
| `Serilog:MinimumLevel:Override:Microsoft.AspNetCore` | `Warning` | Suppress request pipeline logs |
|
||||
| `Serilog:MinimumLevel:Override:Microsoft.EntityFrameworkCore` | `Warning` | Suppress database query logs |
|
||||
|
||||
Log files are written to `logs/echohub-server-YYYY-MM-DD.log`. To change the path or retention, edit the `Serilog:WriteTo` section in `appsettings.json`.
|
||||
|
||||
Want more verbose output for debugging? Set the minimum level to `Debug`:
|
||||
|
||||
```bash
|
||||
# via environment variable
|
||||
export Serilog__MinimumLevel__Default=Debug
|
||||
|
||||
# or command line
|
||||
./EchoHub.Server --Serilog:MinimumLevel:Default=Debug
|
||||
```
|
||||
@@ -0,0 +1,121 @@
|
||||
# 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 environment variables that override `appsettings.json` — the `__` (double underscore) maps to JSON nesting levels. For example, `Server__Name` overrides the `Server:Name` key in `appsettings.json`.
|
||||
|
||||
See the [Configuration](configuration.md) guide for the full reference of all available settings and how the override hierarchy works.
|
||||
|
||||
Common Docker-relevant variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Server__Name` | My EchoHub Server | Display name for your server |
|
||||
| `Server__Admins__0` | *(empty)* | Admin username (use `__1`, `__2` for more) |
|
||||
| `Irc__Enabled` | `false` | Enable the IRC gateway |
|
||||
| `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
|
||||
|
||||
## 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.11
|
||||
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
|
||||
dotnet run --project src/EchoHub.Server
|
||||
```
|
||||
|
||||
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download).
|
||||
|
||||
On first run, the server automatically:
|
||||
|
||||
1. Creates `appsettings.json` from the example config
|
||||
2. Generates a secure JWT secret
|
||||
3. Creates the SQLite database with a `#general` channel
|
||||
|
||||
## Run the Client
|
||||
|
||||
```bash
|
||||
dotnet run --project src/EchoHub.Client
|
||||
```
|
||||
## Usage
|
||||
|
||||
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 via IRC
|
||||
@@ -53,9 +84,9 @@ See the [Architecture](architecture.md) page for details on how the IRC gateway
|
||||
|
||||
## Configuration
|
||||
|
||||
Server configuration is in `appsettings.json` (auto-generated on first run). See the [example config](https://github.com/HueByte/EchoHub/blob/master/src/EchoHub.Server/appsettings.example.json) for all available options.
|
||||
Server configuration is in `appsettings.json` (auto-generated on first run). You can also use environment variables or command-line arguments to override settings.
|
||||
|
||||
To list your server on the [public directory](https://echohub.voidcube.cloud/servers), set `Server:PublicServer` to `true` and `Server:PublicHost` to your server's public address.
|
||||
See the [Configuration](configuration.md) guide for the full reference and how it all works.
|
||||
|
||||
## Build from Source
|
||||
|
||||
|
||||
+16
-8
@@ -1,8 +1,16 @@
|
||||
- name: Getting Started
|
||||
href: getting-started.md
|
||||
- name: Architecture
|
||||
href: architecture.md
|
||||
- name: Encryption
|
||||
href: encryption.md
|
||||
- name: Notification Sounds
|
||||
href: notification-sounds.md
|
||||
- name: Guides
|
||||
items:
|
||||
- name: Getting Started
|
||||
href: getting-started.md
|
||||
- name: Docker
|
||||
href: docker.md
|
||||
- name: Architecture
|
||||
href: architecture.md
|
||||
- name: Configuration
|
||||
href: configuration.md
|
||||
- name: Encryption
|
||||
href: encryption.md
|
||||
- name: Notification Sounds
|
||||
href: notification-sounds.md
|
||||
- name: Flows
|
||||
href: ../flows/toc.yml
|
||||
|
||||
@@ -4,6 +4,13 @@ Release history for EchoHub.
|
||||
|
||||
## Releases
|
||||
|
||||
- [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix
|
||||
- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata
|
||||
- [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes
|
||||
- [v0.2.9](v0.2.9.md) - Install Script & Chocolatey Fixes
|
||||
- [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.4](v0.2.4.md) - E2E Message Encryption
|
||||
- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
- name: Overview
|
||||
href: index.md
|
||||
- name: v0.2.12
|
||||
href: v0.2.12.md
|
||||
- name: v0.2.11
|
||||
href: v0.2.11.md
|
||||
- name: v0.2.10
|
||||
href: v0.2.10.md
|
||||
- name: v0.2.9
|
||||
href: v0.2.9.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
|
||||
href: v0.2.5.md
|
||||
- name: v0.2.4
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# v0.2.10
|
||||
|
||||
Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, infinite-scroll message history, and input polish.
|
||||
|
||||
## New Features
|
||||
|
||||
- Command palette — press Ctrl+K from the message input (or anywhere in the main window) to open a searchable dialog for navigating channels and triggering app actions (connect, disconnect, logout, profile, status, create/delete channel, saved servers, toggle users panel, check for updates, quit). Fuzzy matches against both the label and the underlying key so typing `ch` surfaces channel actions alongside `#channel` entries
|
||||
- Scroll-to-load message history — scrolling to the top of a channel now fetches the next batch of older messages in the background (previously only the most recent 100 messages were available). Duplicate messages are filtered by ID, a per-channel guard prevents concurrent fetches, and the scroll position is preserved after the prepend so your reading position doesn't jump
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fix update progress dialog freezing / not repainting — progress callbacks now run on the UI thread so the download and extraction percentage actually updates while an update is in progress
|
||||
- Fix pre-update backup failing when a Serilog-held log file is locked — `UpdateBackupService` now enumerates files manually, skips the `logs/` directory and `.log` files, and logs-and-continues on `IOException`/`UnauthorizedAccessException` instead of aborting the whole backup
|
||||
- Simplify update progress dispatch — remove redundant `Application.Invoke` wrappers around progress updates that are already called from the UI thread (introduced while fixing the freeze above)
|
||||
- Fix cursor position being reset to the start of the line when auto-completing commands in the CLI app — insertion point is now moved to the end of the completed text
|
||||
- Fix notification sounds crashing or being silently dropped when several arrive in quick succession — playback is now serialized through a semaphore that's held for the duration of each sound (using `PlaybackFinished` with a 10s safety timeout) and always released in `finally`, so back-to-back notifications queue up and play in order instead of racing the underlying audio player (fixes #20)
|
||||
- Fix client crashing on startup with `No Serilog:Using configuration section is defined` under single-file publish — pass `ConfigurationReaderOptions` with the `Serilog.Sinks.File` assembly explicitly so Serilog can resolve sinks without scanning the filesystem for `.dll`s (which don't exist in a bundled exe)
|
||||
|
||||
## Refactoring
|
||||
|
||||
- Move search-dialog dispatch out of `MainWindow` into `AppOrchestrator` — `MainWindow` now just raises `OnSearchRequested`, keeping the view dumb and letting the orchestrator own navigation/action routing
|
||||
- `ChatHub.GetChannelHistory` and `IChatService.GetChannelHistoryAsync` gain an additional `offset` parameter for paginated history loading (defaults to `0` — existing callers are unaffected)
|
||||
- `ValidationConstants.MaxHistoryCount` raised from `100` to `200` so power users and paginated fetches can request larger batches; `DefaultHistoryCount` stays at `100`
|
||||
@@ -0,0 +1,23 @@
|
||||
# v0.2.11
|
||||
|
||||
EchoHubSpace directory protocol overhaul: authenticated server registration with persistent claim tokens, near-real-time user-count updates, and richer server metadata (tags, multi-host, version). Coordinated cutover with the EchoHubSpace directory deploy.
|
||||
|
||||
## New Features
|
||||
|
||||
- EchoHubSpace claim-token authentication — the directory issues a per-server claim token on first registration, persisted atomically alongside the SQLite database (chmod 0600 on Unix). Subsequent reconnects authenticate with the token instead of relying on raw hostname-squatting protection. Token survives both client and directory restarts; lost tokens require an admin-side `DELETE /api/servers/{id}` on the directory to recover
|
||||
- Server tags — public servers can advertise topic tags via the new `Server:Tags` config array, surfacing as filter facets in the EchoHubSpace browser
|
||||
- Multi-host advertisement — a single server can register multiple hostnames (e.g. apex domain, IPv6, alias domains) by listing them in `Server:PublicHosts`. All hosts route to the same directory row
|
||||
- Server version sent to directory — the EchoHubSpace browser shows what version each public server is running, pulled from the server's assembly informational version
|
||||
- Operator-facing `GET /api/server/directory` endpoint (Admin role required) — returns `ServerId`, `IsRegistered`, `LastRegisteredAt`, `LastError`, and any `ConflictingHosts` for support tickets. Never exposes the claim token itself, only a `HasClaimToken` boolean
|
||||
|
||||
## Refactoring
|
||||
|
||||
- Replace 30s polling with event-driven directory updates — `PresenceTracker` now raises `UserCountChanged` only when the distinct user count actually changes (multi-tab/multi-connection users no longer trigger). `ServerDirectoryService` consumes via a single-slot `Channel<int>` (latest-wins coalesces bursts) with a 1-second min-interval throttle. Directory reflects user-count changes within ~1s instead of up to 30s stale
|
||||
- Wrap directory hub responses in a `Response<T>` envelope with `IsSuccess`/`Data`/`Errors`/`Version` shape — protocol version is pinned client-side (currently `1.0`); mismatches trigger a permanent-failure stop with operator-facing log
|
||||
- Stop attempting re-registration after permanent failures (`HostAlreadyClaimed`, `InvalidToken`, `HostConflict`, `InvalidInput`) — the directory no longer terminates the connection on these errors, so the client suppresses re-register on `Reconnected` to avoid tight retry loops. Operator must restart the server after fixing config
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Breaking**: `Server:PublicHost` (string) renamed to `Server:PublicHosts` (string array). Public servers must update `appsettings.json` — single-host deployments use a one-element array
|
||||
- New `Server:Tags` (string array) — defaults to empty
|
||||
- New optional `Server:DirectoryClaimPath` — overrides the path of the persisted claim file. Defaults to a `directory-claim.json` next to the SQLite database. Treat the file as a secret; back it up alongside the database
|
||||
@@ -0,0 +1,49 @@
|
||||
# v0.2.12
|
||||
|
||||
Private channels are now genuinely private: password-protected channels are end-to-end encrypted, so the server (and its operators) can gate joins and measure storage but cannot read message or file contents. The IRC gateway grows real MODE/TOPIC support and channel keys, and the client gets image "save original", a transparent-light theme, drag-and-drop file sending, Ctrl+V paste, and a fix for the intermittent Ctrl+W crash.
|
||||
|
||||
## New Features
|
||||
|
||||
- **End-to-end encrypted channels** — creating a channel with a password now provisions a zero-knowledge room:
|
||||
- The passphrase never leaves the client. It derives (PBKDF2-SHA256, 210k iterations) two keys: an *auth key* sent to the server as the join credential, and a *key-encryption key* that never leaves the machine.
|
||||
- A random room content key encrypts every message and file with AES-256-GCM. The server only ever stores the room key *wrapped* under the passphrase, so it can gate joins and report a channel's message count, storage size, and attachments — but cannot decrypt any of it. Even the server owner cannot read a private room's contents.
|
||||
- Members' clients cache the derived room key locally (in the per-server config, like saved sessions) so the passphrase isn't retyped every launch; joining on a new device prompts for it once.
|
||||
- Change the passphrase with `/passwd <old> <new>` (channel creator only). The room key is re-wrapped, not rotated, so **existing history stays readable** and members who join later with the new passphrase can still read older messages.
|
||||
- Files and images are encrypted client-side before upload; for images the ASCII-art preview is rendered on the client and stored room-encrypted too. Sending images by URL is disabled in encrypted channels (the server can't fetch-and-render without the key).
|
||||
- End-to-end encrypted channels cannot be joined over the IRC gateway (that would require the server to hold the room key) — IRC `JOIN` returns `475` directing users to the EchoHub client.
|
||||
- Password-protected channels — set an optional password when creating a channel (masked field in the Create Channel dialog, `password` on `POST /api/channels`). Passwords are BCrypt-hashed server-side; the join gate applies on first join only (existing members and the creator are unaffected). Protected channels show a `*` marker in the channel list and `+k` in the status bar
|
||||
- Save original images — image messages now show a clickable "[↓ save original]" line under the ASCII-art preview that downloads the full-resolution original to your Downloads folder (decrypting locally in encrypted channels)
|
||||
- **Messages with attachments (Discord-style)** — a message is now text **plus** a list of attachments instead of being either text or a single file. One message can carry a caption and several files (images, audio, docs) together:
|
||||
- Compose with a **staging tray**: `/send <file>` or dropping files onto the terminal stages them (shown on the input bar); the next Enter sends your typed caption and all staged files as one message. `/clear` drops staged files. `/send <URL>` still posts an image immediately.
|
||||
- Each image attachment renders its own ASCII preview with its own "save original" action; audio/file attachments each get their own play/download line.
|
||||
- In encrypted channels every attachment is encrypted individually (blob + ASCII preview), and the caption is room-encrypted — the server still stores only ciphertext and can report count/size but not contents.
|
||||
- Up to 10 attachments per message.
|
||||
- **Right-click message menu** — right-click any message for a context menu: save/download/play its attachment, mention the sender, view their profile, copy the text, copy the message ID (for linking or command arguments), or delete the message. (Keyboard: F6 focuses the message list for arrow-key selection + Delete.) The selected message is now highlighted while the list is focused.
|
||||
- **Message deletion** — press Delete on a selected message to remove it. You can always delete your own messages; moderators and above can delete others' messages, but only from users **below their own role** (a mod can't delete an admin's or owner's message). Deleting a message also removes its attachment blobs from server storage.
|
||||
- **Customizable download folder** — `/downloadpath` opens your OS-native folder picker (Windows Explorer / macOS Finder / Linux GTK or KDE) to choose where downloaded attachments and saved images go; `/downloadpath <path>` sets it directly (the fallback when no native picker is available). Downloaded files now land in that folder (with automatic `(n)` de-duplication) instead of a temp directory.
|
||||
- `/join <channel> [password]` — join protected channels inline, or let the client prompt: joining a protected channel without a password opens a masked prompt that re-prompts on a wrong password
|
||||
- IRC channel keys — `JOIN #room <key>` works against room passwords (RFC 1459 comma-paired key lists supported); keyless or wrong-key joins get `475 ERR_BADCHANNELKEY`
|
||||
- IRC `MODE` implemented — `MODE #chan` reports `+k`/`+`, `MODE #chan +k <key>` sets and `-k` clears the room password (channel creator or admin only), ban-list probes get a clean empty reply, and `CHANMODES` is advertised in ISUPPORT
|
||||
- IRC `TOPIC` set support — the channel creator can change the topic from IRC; the change broadcasts to connected TUI clients (previously topic changes were rejected with a stub error)
|
||||
- Attach a file by drag & drop or by pasting — drop a file onto the terminal, or **copy a file in your file manager and press Ctrl+V**, to stage it as an attachment (the next Enter sends it with your caption). Multiple files at once are supported. Ctrl+V still pastes text when the clipboard holds text; Ctrl+Y is a paste alias. On Windows the copied-file paste reads the clipboard's file list directly (Windows Terminal never pastes copied files as text), with `xclip`/`wl-paste` used on Linux
|
||||
- Pick ASCII-art size for attached images — `/size` opens a Small/Medium/Large picker (40×40 / 80×80 / 120×120) with descriptions, `/size <s|m|l>` sets it directly, and `/send <file> -l` sets it for that message. The choice is a saved preference and applies to copy-paste/drag-drop images (which have no per-file flag); the current size is shown in the staging tray
|
||||
- New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`)
|
||||
- Timestamps in messages are now aware of the current culture and display the short time pattern for today's messages and the short date+time pattern for older messages.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Transparent themes no longer draw an opaque box behind the message input. The input `TextView` renders with the `Editable` visual role, which Terminal.Gui derives as an opaque color when a theme leaves it unset; the themes now pin `Editable`/`ReadOnly` to their base colors so the input matches its (transparent) background.
|
||||
- Attachments whose files have been pruned (retention cleanup deletes blobs older than `Storage:RetentionDays` but left the message rows) no longer render a dead download/preview. When channel history loads, the server checks which attachment blobs still exist: missing ones are dropped from the message, and an attachment-only message whose files are all gone is removed from the database.
|
||||
- Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing
|
||||
- Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement
|
||||
- IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]`
|
||||
|
||||
## API Changes
|
||||
|
||||
- `ChannelDto` gains `isProtected` and `isEncrypted`; `CreateChannelRequest` gains optional `password`, `encryptionSalt`, and `wrappedRoomKey`; SignalR `JoinChannel` takes an optional second `password` argument and `JoinChannelResult` gains `passwordRequired`, `encryptionSalt`, and `wrappedRoomKey` (older clients must update to join over SignalR)
|
||||
- New endpoints: `GET /api/channels/{channel}/crypto` (public crypto metadata — salt only, never the wrapped key) and `POST /api/channels/{channel}/rekey` (creator-only passphrase change)
|
||||
- The upload endpoint accepts `type` and `content` form fields for encrypted channels, where the client supplies the declared message type and room-encrypted content
|
||||
- `ImageToAsciiService` and `FileValidationHelper` moved from `EchoHub.Server` to `EchoHub.Core` so the client can render ASCII art and detect file types for encrypted uploads
|
||||
- **Message shape change**: `MessageDto` drops `Type`/`AttachmentUrl`/`AttachmentFileName`/`AttachmentFileSize` and gains `Attachments` (a list of `AttachmentDto { Kind, Url, FileName, FileSize, AsciiPreview }`, null/empty for plain text). New `Attachment` entity + table with a cascade FK to `Message`
|
||||
- New endpoint `POST /api/channels/{channel}/messages` (multipart: `content` + N `files`, plus `kind`/`preview` per file for encrypted channels) replaces the single-file `upload` endpoint; `DELETE /api/moderation/messages/{id}` now enforces the own-or-higher-role rule
|
||||
- New EF migrations `AddChannelPasswordHash`, `AddChannelEncryptionEnvelope`, and `AddMessageAttachments` (applied automatically on server start); a one-time startup data migration folds legacy single-attachment messages into the new model
|
||||
@@ -14,6 +14,8 @@
|
||||
- Moved `AsyncRunner` from project root to `Services/` with updated namespace
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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,23 @@
|
||||
# v0.2.9
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fix Linux/macOS client install — enable single-file publish so the install script copies one self-contained binary instead of just the native host (which failed with "does not exist: EchoHub.Client.dll")
|
||||
- Fix Chocolatey install path on Windows — `chocolateyInstall.ps1` was joining the install directory and executable name into a single segment, producing an invalid target path
|
||||
- Fix Chocolatey package metadata — corrected GitHub repository URLs and documentation URLs in `echohub.nuspec` that pointed at the wrong location
|
||||
- Fix double-click on "Public" checkbox in the Create Channel dialog accidentally submitting the dialog — checkbox toggle commands no longer bubble up to the dialog's default button
|
||||
|
||||
## Documentation
|
||||
|
||||
- Add a dedicated configuration guide (`docs/articles/configuration.md`) covering server settings, client settings, and environment overrides
|
||||
- Refresh README badges and reorganize the articles table of contents for better discoverability
|
||||
- Polish Docker, getting-started, and flow docs to match the current configuration surface
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Bump `Terminal.Gui` to `2.0.0-develop.5043` (from `5039`)
|
||||
|
||||
## CI
|
||||
|
||||
- Release workflow now publishes a single-file self-contained client binary for Linux and macOS so the install script works out-of-the-box
|
||||
- Chocolatey publishing step now triggers only when the package source actually changes and performs a proper version check against the feed before pushing
|
||||
+1
-1
@@ -85,7 +85,7 @@
|
||||
"_appTitle": "EchoHub Documentation",
|
||||
"_appLogoPath": "images/hue_icon.svg",
|
||||
"_appFaviconPath": "images/hue_icon.svg",
|
||||
"_appFooter": "<div class='footer-custom'><div class='footer-inner'><span class='footer-brand'>EchoHub</span><span class='footer-sep'>·</span><a href='https://github.com/HueByte/EchoHub'>GitHub</a><span class='footer-sep'>·</span><a href='https://echohub.voidcube.cloud'>Website</a><span class='footer-sep'>·</span><span class='footer-credit'>Built with <a href='https://dotnet.github.io/docfx'>DocFX</a></span></div></div>",
|
||||
"_appFooter": "<div class='footer-custom'><div class='footer-inner'><span class='footer-brand'>EchoHub</span><span class='footer-sep'>·</span><a href='https://github.com/HueByte/EchoHub'>GitHub</a><span class='footer-sep'>·</span><a href='https://echohub.voidcube.cloud'>Website</a></div></div>",
|
||||
"_enableSearch": true,
|
||||
"_disableContribution": false,
|
||||
"_gitContribute": {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# 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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -0,0 +1,124 @@
|
||||
# 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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -0,0 +1,116 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -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,89 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
```
|
||||
@@ -0,0 +1,168 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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
|
||||
```
|
||||
@@ -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
-13
@@ -49,6 +49,7 @@ header.top-navbar {
|
||||
.navbar .navbar-brand svg {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.navbar .nav-link {
|
||||
@@ -320,19 +321,6 @@ footer {
|
||||
color: #E6C06E;
|
||||
}
|
||||
|
||||
.footer-credit {
|
||||
color: #484f58;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.footer-credit a {
|
||||
color: #6e7681;
|
||||
}
|
||||
|
||||
.footer-credit a:hover {
|
||||
color: #E6C06E;
|
||||
}
|
||||
|
||||
/* --- Scrollbar --- */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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
|
||||
- [x] password protected rooms (end-to-end encrypted — server cannot read contents)
|
||||
- [ ] 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
|
||||
- [x] 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)
|
||||
- [x] 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
|
||||
- [ ] Embeds still incorrectly display colors
|
||||
@@ -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, StoneRed</authors>
|
||||
<owners>HueByte</owners>
|
||||
<requireLicenseAcceptance>false</requireLicenseAcceptance>
|
||||
<licenseUrl>https://github.com/HueByte/EchoHub/blob/master/LICENSE</licenseUrl>
|
||||
<projectUrl>https://echohub.voidcube.cloud/</projectUrl>
|
||||
<projectSourceUrl>https://github.com/HueByte/EchoHub</projectSourceUrl>
|
||||
<docsUrl>https://huebyte.github.io/EchoHub</docsUrl>
|
||||
<bugTrackerUrl>https://github.com/HueByte/EchoHub/issues</bugTrackerUrl>
|
||||
<packageSourceUrl>https://github.com/HueByte/EchoHub/tree/master/packaging/choco</packageSourceUrl>
|
||||
<iconUrl>https://raw.githubusercontent.com/HueByte/EchoHub/refs/heads/master/assets/hue_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://huebyte.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,19 @@
|
||||
$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
|
||||
$exeDir = Join-Path $toolsDir 'client-win-x64'
|
||||
$exePath = Join-Path $exeDir 'EchoHub.Client.exe'
|
||||
Install-BinFile -Name 'echohub' -Path $exePath
|
||||
@@ -0,0 +1,3 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Uninstall-BinFile -Name 'echohub'
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/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.11"
|
||||
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 full directory to ~/.local/share/echohub and symlink the binary.
|
||||
# Even single-file publishes may have content files (appsettings, assets)
|
||||
# that the app expects next to the binary via AppContext.BaseDirectory.
|
||||
app_dir="$HOME/.local/share/echohub"
|
||||
rm -rf "$app_dir"
|
||||
mkdir -p "$app_dir"
|
||||
cp -r "$src_dir"/. "$app_dir/"
|
||||
chmod +x "$app_dir/EchoHub.Client"
|
||||
|
||||
mkdir -p "$install_dir"
|
||||
ln -sf "$app_dir/EchoHub.Client" "$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>
|
||||
<PropertyGroup>
|
||||
<Version>0.2.6</Version>
|
||||
<Version>0.2.12</Version>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -8,6 +8,8 @@ using EchoHub.Client.UI.Dialogs;
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Core.Security;
|
||||
using EchoHub.Core.Services;
|
||||
using Serilog;
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Views;
|
||||
@@ -28,6 +30,10 @@ public sealed class AppOrchestrator : IDisposable
|
||||
private readonly AudioPlaybackService _audioPlayback = new();
|
||||
private readonly UpdateChecker _updateService;
|
||||
private readonly ConnectionManager _conn = new();
|
||||
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _channelUsersLock = new();
|
||||
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<string> _stagedAttachments = [];
|
||||
|
||||
private ClientConfig _config;
|
||||
private readonly UserSession _session = new();
|
||||
@@ -85,6 +91,14 @@ public sealed class AppOrchestrator : IDisposable
|
||||
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
||||
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
||||
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
|
||||
_mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested;
|
||||
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
||||
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
||||
_mainWindow.OnUserProfileRequested += HandleViewProfile;
|
||||
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
||||
_mainWindow.OnSearchRequested += HandleSearchRequested;
|
||||
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
|
||||
}
|
||||
|
||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||
@@ -100,6 +114,10 @@ public sealed class AppOrchestrator : IDisposable
|
||||
_commandHandler.OnOpenProfile += HandleCmdOpenProfile;
|
||||
_commandHandler.OnOpenServers += HandleCmdOpenServers;
|
||||
_commandHandler.OnJoinChannel += HandleCmdJoinChannel;
|
||||
_commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword;
|
||||
_commandHandler.OnClearAttachments += HandleCmdClearAttachments;
|
||||
_commandHandler.OnSetAsciiSize += HandleCmdSetAsciiSize;
|
||||
_commandHandler.OnSetDownloadPath += HandleCmdSetDownloadPath;
|
||||
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
|
||||
_commandHandler.OnSetTopic += HandleCmdSetTopic;
|
||||
_commandHandler.OnListUsers += HandleCmdListUsers;
|
||||
@@ -149,32 +167,175 @@ public sealed class AppOrchestrator : IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandleCmdSendFile(string target, string? size)
|
||||
private Task HandleCmdSendFile(string target, string? size)
|
||||
{
|
||||
if (!_conn.IsAuthenticated || !_conn.IsConnected) return;
|
||||
if (!_conn.IsAuthenticated || !_conn.IsConnected) return Task.CompletedTask;
|
||||
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return;
|
||||
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||
|
||||
try
|
||||
// A URL image is sent immediately as its own message (it can't be staged/encrypted).
|
||||
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||
{
|
||||
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||
if (_conn.RoomKeys.HasKey(channel))
|
||||
{
|
||||
await _conn.Api!.SendUrlAsync(channel, target, size);
|
||||
InvokeUI(() => _mainWindow.ShowError(
|
||||
"Sending by URL isn't available in encrypted channels — download the file and /send it instead."));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
RunAsync(async () => await _conn.Api!.SendUrlAsync(channel, target, size), "Send failed");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Local files are staged; the next Enter sends them with the typed caption as one message.
|
||||
if (_stagedAttachments.Count >= HubConstants.MaxAttachmentsPerMessage)
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message."));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// An explicit "-s/-m/-l" on /send also sets the message's ASCII size.
|
||||
if (NormalizeAsciiSize(size) is { } flag)
|
||||
_config.DefaultAsciiSize = flag;
|
||||
|
||||
_stagedAttachments.Add(target);
|
||||
InvokeUI(RefreshStagingTray);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task HandleCmdClearAttachments()
|
||||
{
|
||||
_stagedAttachments.Clear();
|
||||
InvokeUI(RefreshStagingTray);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the ASCII-art size picker (no argument) or sets it directly from "s"/"m"/"l" (or
|
||||
/// small/medium/large). The choice is a persistent preference applied to attached images.
|
||||
/// </summary>
|
||||
private Task HandleCmdSetAsciiSize(string args)
|
||||
{
|
||||
var flag = NormalizeAsciiSize(args);
|
||||
if (flag is not null)
|
||||
{
|
||||
InvokeUI(() => ApplyAsciiSize(flag));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
InvokeUI(() =>
|
||||
{
|
||||
var choice = MessageBox.Query(_app, "ASCII Art Size",
|
||||
"Size of the ASCII rendering for images you attach:\n\n"
|
||||
+ " Small 40 x 40 (compact)\n"
|
||||
+ " Medium 80 x 80 (default)\n"
|
||||
+ " Large 120 x 120 (detailed)",
|
||||
"Small", "Medium", "Large", "Cancel");
|
||||
|
||||
var picked = choice switch { 0 => "s", 1 => "m", 2 => "l", _ => null };
|
||||
if (picked is not null)
|
||||
ApplyAsciiSize(picked);
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ApplyAsciiSize(string flag)
|
||||
{
|
||||
_config.DefaultAsciiSize = flag;
|
||||
ConfigManager.Save(_config);
|
||||
RefreshStagingTray();
|
||||
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (!string.IsNullOrEmpty(channel))
|
||||
_messageManager.AddSystemMessage(channel, $"Image ASCII size set to {AsciiSizeLabel(flag)}.");
|
||||
}
|
||||
|
||||
/// <summary>Refreshes the staging tray with the current staged files and ASCII size.</summary>
|
||||
private void RefreshStagingTray()
|
||||
{
|
||||
var names = _stagedAttachments.Select(Path.GetFileName).OfType<string>().ToList();
|
||||
_mainWindow.SetStagedAttachments(names, AsciiSizeLabel(_config.DefaultAsciiSize));
|
||||
}
|
||||
|
||||
private static string? NormalizeAsciiSize(string? size) => size?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"s" or "small" => "s",
|
||||
"m" or "medium" => "m",
|
||||
"l" or "large" => "l",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string AsciiSizeLabel(string flag) => flag switch
|
||||
{
|
||||
"s" => "Small (40x40)",
|
||||
"l" => "Large (120x120)",
|
||||
_ => "Medium (80x80)",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Sends one message with the given caption plus all staged files as attachments, then
|
||||
/// clears the staging tray. In encrypted channels each file is room-encrypted (blob +
|
||||
/// ASCII preview) client-side before upload; the caption is room-encrypted too.
|
||||
/// </summary>
|
||||
private void SendStagedMessage(string channel, string content)
|
||||
{
|
||||
var staged = _stagedAttachments.ToList();
|
||||
_stagedAttachments.Clear();
|
||||
InvokeUI(RefreshStagingTray);
|
||||
|
||||
var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey);
|
||||
var size = _config.DefaultAsciiSize;
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
var outgoing = new List<OutgoingAttachment>();
|
||||
foreach (var path in staged)
|
||||
outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size));
|
||||
|
||||
var wireContent = hasRoomKey && !string.IsNullOrEmpty(content)
|
||||
? RoomCrypto.EncryptText(content, roomKey)
|
||||
: content;
|
||||
|
||||
await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size);
|
||||
}, "Send failed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a staged file into an <see cref="OutgoingAttachment"/>. For encrypted channels the
|
||||
/// blob is AES-GCM encrypted, its kind is declared, and the image ASCII preview is rendered
|
||||
/// locally (at <paramref name="size"/>) and room-encrypted — so the server never sees the
|
||||
/// file or image contents.
|
||||
/// </summary>
|
||||
private static async Task<OutgoingAttachment> BuildOutgoingAttachmentAsync(string path, byte[]? roomKey, string size)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
|
||||
if (roomKey is null)
|
||||
return new OutgoingAttachment(File.OpenRead(path), fileName);
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(path);
|
||||
string declaredKind;
|
||||
string? preview = null;
|
||||
|
||||
using (var ms = new MemoryStream(bytes))
|
||||
{
|
||||
if (FileValidationHelper.IsValidImage(ms))
|
||||
{
|
||||
declaredKind = "image";
|
||||
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||
ms.Position = 0;
|
||||
preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var stream = File.OpenRead(target);
|
||||
var fileName = Path.GetFileName(target);
|
||||
await _conn.Api!.UploadFileAsync(channel, stream, fileName, size);
|
||||
declaredKind = FileValidationHelper.IsAudioFile(fileName) ? "audio" : "file";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "File send failed for {Target}", target);
|
||||
InvokeUI(() => _mainWindow.ShowError($"Send failed: {ex.Message}"));
|
||||
}
|
||||
|
||||
var encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey);
|
||||
return new OutgoingAttachment(new MemoryStream(encryptedBlob), fileName, declaredKind, preview);
|
||||
}
|
||||
|
||||
private async Task HandleCmdSetAvatar(string target)
|
||||
@@ -207,13 +368,15 @@ public sealed class AppOrchestrator : IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandleCmdJoinChannel(string channelName)
|
||||
private async Task HandleCmdJoinChannel(string channelName, string? password)
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
|
||||
try
|
||||
{
|
||||
var history = await _conn.JoinChannelAsync(channelName);
|
||||
var history = await JoinChannelWithPasswordPromptAsync(channelName, password);
|
||||
if (history is null) return; // user cancelled the password prompt
|
||||
|
||||
InvokeUI(() =>
|
||||
{
|
||||
_mainWindow.EnsureChannelInList(channelName);
|
||||
@@ -228,6 +391,145 @@ public sealed class AppOrchestrator : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Joins a channel, prompting for a password when the server requires one and
|
||||
/// re-prompting on a wrong password. For end-to-end encrypted channels the typed
|
||||
/// passphrase never goes to the server — a PBKDF2-derived auth key is sent instead,
|
||||
/// and the room content key is unwrapped locally. Returns the channel history,
|
||||
/// or null if the user cancelled the prompt.
|
||||
/// </summary>
|
||||
private async Task<List<MessageDto>?> JoinChannelWithPasswordPromptAsync(string channelName, string? password)
|
||||
{
|
||||
ChannelCryptoDto? crypto = null;
|
||||
try
|
||||
{
|
||||
crypto = await _conn.Api!.GetChannelCryptoAsync(channelName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Crypto metadata unavailable for {Channel}", channelName);
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
byte[]? kek = null;
|
||||
var wirePassword = password;
|
||||
if (password is not null && crypto is { IsEncrypted: true, EncryptionSalt: not null })
|
||||
{
|
||||
var derived = RoomCrypto.DeriveKeys(password, Convert.FromBase64String(crypto.EncryptionSalt));
|
||||
wirePassword = derived.AuthKeyHex;
|
||||
kek = derived.KeyEncryptionKey;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var outcome = await _conn.JoinChannelAsync(channelName, wirePassword);
|
||||
|
||||
if (outcome.WrappedRoomKey is not null && !_conn.RoomKeys.HasKey(channelName))
|
||||
{
|
||||
if (kek is not null && RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, kek, out var roomKey))
|
||||
{
|
||||
_conn.RoomKeys.StoreKey(channelName, roomKey);
|
||||
// Re-fetch so history decrypts with the now-available room key
|
||||
return await _conn.GetHistoryAsync(channelName);
|
||||
}
|
||||
|
||||
return await UnlockRoomKeyAsync(channelName, outcome);
|
||||
}
|
||||
|
||||
return outcome.History;
|
||||
}
|
||||
catch (ChannelPasswordRequiredException ex)
|
||||
{
|
||||
var prompt = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var message = password is not null ? ex.Message : null;
|
||||
InvokeUI(() => prompt.SetResult(ChannelPasswordDialog.Show(_app, channelName, message)));
|
||||
|
||||
password = await prompt.Task;
|
||||
if (password is null) return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Member of an encrypted channel without a cached room key (e.g. a new device):
|
||||
/// prompt for the passphrase until the room key unwraps or the user gives up.
|
||||
/// </summary>
|
||||
private async Task<List<MessageDto>?> UnlockRoomKeyAsync(string channelName, JoinOutcome outcome)
|
||||
{
|
||||
if (outcome.EncryptionSalt is null || outcome.WrappedRoomKey is null)
|
||||
return outcome.History;
|
||||
|
||||
var salt = Convert.FromBase64String(outcome.EncryptionSalt);
|
||||
var message = "Enter the passphrase to unlock messages.";
|
||||
|
||||
while (true)
|
||||
{
|
||||
var prompt = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var promptMessage = message;
|
||||
InvokeUI(() => prompt.SetResult(ChannelPasswordDialog.Show(_app, channelName, promptMessage)));
|
||||
|
||||
var passphrase = await prompt.Task;
|
||||
if (passphrase is null)
|
||||
return outcome.History; // stays locked; placeholders render instead of content
|
||||
|
||||
var derived = RoomCrypto.DeriveKeys(passphrase, salt);
|
||||
if (RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, derived.KeyEncryptionKey, out var roomKey))
|
||||
{
|
||||
_conn.RoomKeys.StoreKey(channelName, roomKey);
|
||||
return await _conn.GetHistoryAsync(channelName);
|
||||
}
|
||||
|
||||
message = "Wrong passphrase — try again.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the current encrypted channel's passphrase: re-derives the join credential
|
||||
/// and re-wraps the cached room content key under the new passphrase. History is
|
||||
/// never re-encrypted — the room key itself doesn't change.
|
||||
/// </summary>
|
||||
private async Task HandleCmdChangeRoomPassword(string oldPassphrase, string newPassphrase)
|
||||
{
|
||||
if (!_conn.IsAuthenticated || !_conn.IsConnected) return;
|
||||
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var crypto = await _conn.Api!.GetChannelCryptoAsync(channel);
|
||||
if (crypto is not { IsEncrypted: true } || crypto.EncryptionSalt is null)
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError($"#{channel} is not an end-to-end encrypted channel."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_conn.RoomKeys.TryGetKey(channel, out var roomKey))
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError("Unlock this channel first (rejoin it with its passphrase), then retry."));
|
||||
return;
|
||||
}
|
||||
|
||||
var oldDerived = RoomCrypto.DeriveKeys(oldPassphrase, Convert.FromBase64String(crypto.EncryptionSalt));
|
||||
var newSalt = RoomCrypto.GenerateSalt();
|
||||
var newDerived = RoomCrypto.DeriveKeys(newPassphrase, newSalt);
|
||||
|
||||
await _conn.Api!.RekeyChannelAsync(channel, new RekeyChannelRequest(
|
||||
oldDerived.AuthKeyHex,
|
||||
newDerived.AuthKeyHex,
|
||||
Convert.ToBase64String(newSalt),
|
||||
RoomCrypto.WrapRoomKey(roomKey, newDerived.KeyEncryptionKey)));
|
||||
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(channel,
|
||||
"Passphrase changed. History stays readable; new members and new devices need the new passphrase."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError($"Passphrase change failed: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCmdLeaveChannel()
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
@@ -379,17 +681,48 @@ public sealed class AppOrchestrator : IDisposable
|
||||
}
|
||||
};
|
||||
|
||||
_conn.UserJoined += (channelName, username) =>
|
||||
_conn.UserJoined += (channelName, username, presence) =>
|
||||
{
|
||||
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();
|
||||
};
|
||||
|
||||
_conn.UserLeft += (channelName, username) =>
|
||||
{
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -405,18 +738,75 @@ public sealed class AppOrchestrator : IDisposable
|
||||
foreach (var channelName in _mainWindow.GetChannelNames())
|
||||
_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) =>
|
||||
{
|
||||
var reasonText = reason is not null ? $" ({reason})" : "";
|
||||
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) =>
|
||||
{
|
||||
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(() =>
|
||||
{
|
||||
if (!username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -424,6 +814,9 @@ public sealed class AppOrchestrator : IDisposable
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (!string.IsNullOrEmpty(channel))
|
||||
_messageManager.AddSystemMessage(channel, $"{username} was banned{reasonText}");
|
||||
|
||||
if (snapshot is not null)
|
||||
_mainWindow.UpdateOnlineUsers(snapshot);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -454,7 +847,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
InvokeUI(() =>
|
||||
{
|
||||
if (channel.IsPublic)
|
||||
_mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic);
|
||||
_mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic, channel.IsProtected);
|
||||
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
||||
});
|
||||
};
|
||||
@@ -467,6 +860,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
|
||||
_conn.Reconnected += () =>
|
||||
{
|
||||
lock (_channelUsersLock) _channelUsers.Clear();
|
||||
RunAsync(
|
||||
async () => await _conn.RejoinChannelsAsync(),
|
||||
"Failed to rejoin channels after reconnect");
|
||||
@@ -524,9 +918,8 @@ public sealed class AppOrchestrator : IDisposable
|
||||
if (result.DefaultHistory.Count > 0)
|
||||
_messageManager.LoadHistory(HubConstants.DefaultChannel, result.DefaultHistory);
|
||||
_mainWindow.FocusInput();
|
||||
FetchAndUpdateOnlineUsers();
|
||||
});
|
||||
|
||||
FetchAndUpdateOnlineUsers();
|
||||
SaveServerToConfig(dialogResult);
|
||||
}, "Connection failed", "Connect");
|
||||
}
|
||||
@@ -534,6 +927,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
private void HandleDisconnect()
|
||||
{
|
||||
Log.Information("Disconnecting from server");
|
||||
lock (_channelUsersLock) _channelUsers.Clear();
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
@@ -594,11 +988,28 @@ public sealed class AppOrchestrator : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// Staged files → one message with the typed caption plus those attachments.
|
||||
if (_stagedAttachments.Count > 0)
|
||||
{
|
||||
SendStagedMessage(channelName, content);
|
||||
return;
|
||||
}
|
||||
|
||||
RunAsync(
|
||||
async () => await _conn.SendMessageAsync(channelName, content),
|
||||
"Send failed");
|
||||
}
|
||||
|
||||
private void HandleDeleteMessageRequested(Guid messageId)
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return;
|
||||
|
||||
// The server enforces the hierarchy rule (own message, or Mod+ over a strictly
|
||||
// lower role) and broadcasts the deletion; the local list updates on that event.
|
||||
RunAsync(async () => await _conn.Api!.DeleteMessageAsync(messageId),
|
||||
"Failed to delete message");
|
||||
}
|
||||
|
||||
private void HandleChannelSelected(string channelName)
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
@@ -606,7 +1017,16 @@ public sealed class AppOrchestrator : IDisposable
|
||||
RunAsync(async () =>
|
||||
{
|
||||
if (_conn.TrackChannel(channelName))
|
||||
await _conn.JoinChannelAsync(channelName);
|
||||
{
|
||||
var joined = await JoinChannelWithPasswordPromptAsync(channelName, null);
|
||||
if (joined is null)
|
||||
{
|
||||
// User cancelled the password prompt — back to the default channel
|
||||
_conn.UntrackChannel(channelName);
|
||||
InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -622,6 +1042,76 @@ public sealed class AppOrchestrator : IDisposable
|
||||
}, "Failed to join channel");
|
||||
}
|
||||
|
||||
private void HandleLoadMoreRequested()
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return;
|
||||
|
||||
if (!_channelsLoadingMore.Add(channel)) return;
|
||||
|
||||
var offset = _messageManager.GetMessages(channel)?.Count ?? 0;
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var history = await _conn.GetHistoryAsync(channel, HubConstants.DefaultHistoryCount, offset);
|
||||
InvokeUI(() => _messageManager.PrependHistory(channel, history));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_channelsLoadingMore.Remove(channel);
|
||||
}
|
||||
}, "Failed to load more messages");
|
||||
}
|
||||
|
||||
private void HandleChannelJoinFromMessage(string channelName)
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
|
||||
InvokeUI(() =>
|
||||
{
|
||||
_mainWindow.EnsureChannelInList(channelName);
|
||||
_mainWindow.SwitchToChannel(channelName);
|
||||
});
|
||||
|
||||
HandleChannelSelected(channelName);
|
||||
}
|
||||
|
||||
|
||||
private void HandleSearchRequested()
|
||||
{
|
||||
var result = SearchDialog.Show(_app, _mainWindow.GetChannelNames());
|
||||
if (result is null) return;
|
||||
|
||||
switch (result.Type)
|
||||
{
|
||||
case SearchResultType.Channel:
|
||||
_mainWindow.SwitchToChannel(result.Key);
|
||||
HandleChannelSelected(result.Key);
|
||||
break;
|
||||
|
||||
case SearchResultType.Action:
|
||||
switch (result.Key)
|
||||
{
|
||||
case "connect": HandleConnect(); break;
|
||||
case "disconnect": HandleDisconnect(); break;
|
||||
case "logout": HandleLogout(); break;
|
||||
case "profile": HandleProfileRequested(); break;
|
||||
case "status": HandleStatusRequested(); break;
|
||||
case "create-channel": HandleCreateChannelRequested(); break;
|
||||
case "delete-channel": HandleDeleteChannelRequested(); break;
|
||||
case "servers": HandleSavedServersRequested(); break;
|
||||
case "toggle-users": _mainWindow.ToggleUsersPanel(); break;
|
||||
case "updates": HandleCheckForUpdatesRequested(); break;
|
||||
case "quit": _app.RequestStop(); break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleProfileRequested()
|
||||
{
|
||||
HandleViewProfile(null);
|
||||
@@ -812,19 +1302,46 @@ public sealed class AppOrchestrator : IDisposable
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic);
|
||||
// Password rooms are end-to-end encrypted: derive the join credential and
|
||||
// wrap a fresh room content key locally — the passphrase never leaves here.
|
||||
string? wirePassword = null, saltB64 = null, wrappedKey = null;
|
||||
byte[]? roomKey = null;
|
||||
if (result.Password is not null)
|
||||
{
|
||||
if (result.Password.Length < ValidationConstants.MinChannelPasswordLength)
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError(
|
||||
$"Channel password must be at least {ValidationConstants.MinChannelPasswordLength} characters."));
|
||||
return;
|
||||
}
|
||||
|
||||
var salt = RoomCrypto.GenerateSalt();
|
||||
var derived = RoomCrypto.DeriveKeys(result.Password, salt);
|
||||
roomKey = RoomCrypto.GenerateRoomKey();
|
||||
wirePassword = derived.AuthKeyHex;
|
||||
saltB64 = Convert.ToBase64String(salt);
|
||||
wrappedKey = RoomCrypto.WrapRoomKey(roomKey, derived.KeyEncryptionKey);
|
||||
}
|
||||
|
||||
var channel = await _conn.Api!.CreateChannelAsync(
|
||||
result.Name, result.Topic, result.IsPublic, wirePassword, saltB64, wrappedKey);
|
||||
if (channel is null) return;
|
||||
|
||||
var history = await _conn.JoinChannelAsync(channel.Name);
|
||||
if (roomKey is not null)
|
||||
_conn.RoomKeys.StoreKey(channel.Name, roomKey);
|
||||
|
||||
var history = (await _conn.JoinChannelAsync(channel.Name)).History;
|
||||
|
||||
InvokeUI(() =>
|
||||
{
|
||||
_mainWindow.EnsureChannelInList(channel.Name);
|
||||
_mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic, channel.IsProtected);
|
||||
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
||||
_mainWindow.SwitchToChannel(channel.Name);
|
||||
if (history.Count > 0)
|
||||
_messageManager.LoadHistory(channel.Name, history);
|
||||
});
|
||||
|
||||
FetchAndUpdateOnlineUsers();
|
||||
}, "Failed to create channel");
|
||||
}
|
||||
|
||||
@@ -875,11 +1392,95 @@ public sealed class AppOrchestrator : IDisposable
|
||||
RunAsync(async () =>
|
||||
{
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||
InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName));
|
||||
}, "Failed to play audio");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads an attachment to a temp file, decrypting it locally when the current
|
||||
/// channel is end-to-end encrypted (the server stores those blobs as ciphertext).
|
||||
/// </summary>
|
||||
private async Task<string> DownloadAttachmentAsync(string attachmentUrl, string fileName)
|
||||
{
|
||||
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (!string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out var roomKey))
|
||||
{
|
||||
try
|
||||
{
|
||||
var blob = await File.ReadAllBytesAsync(tempPath);
|
||||
await File.WriteAllBytesAsync(tempPath, RoomCrypto.DecryptBytes(blob, roomKey));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Attachment {File} did not decrypt with the room key — keeping raw bytes", fileName);
|
||||
}
|
||||
}
|
||||
|
||||
return tempPath;
|
||||
}
|
||||
|
||||
private void HandleImageSaveRequested(string attachmentUrl, string fileName)
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return;
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||
|
||||
var destination = DedupPath(GetDownloadDir(), fileName);
|
||||
File.Move(tempPath, destination);
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Image saved to: {destination}"));
|
||||
}, "Failed to save image");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the folder downloads are written to: the user's configured
|
||||
/// <see cref="ClientConfig.DownloadPath"/> if set, otherwise the OS Downloads folder.
|
||||
/// Falls back to the temp folder if neither can be created.
|
||||
/// </summary>
|
||||
private string GetDownloadDir()
|
||||
{
|
||||
var dir = _config.DownloadPath;
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Download folder {Dir} is not usable; falling back to temp", dir);
|
||||
return Path.GetTempPath();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Appends " (n)" before the extension until the path doesn't collide with an existing file.</summary>
|
||||
private static string DedupPath(string dir, string fileName)
|
||||
{
|
||||
var stem = Path.GetFileNameWithoutExtension(fileName);
|
||||
var ext = Path.GetExtension(fileName);
|
||||
var dest = Path.Combine(dir, fileName);
|
||||
for (var i = 1; File.Exists(dest); i++)
|
||||
dest = Path.Combine(dir, $"{stem} ({i}){ext}");
|
||||
return dest;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return;
|
||||
@@ -887,21 +1488,114 @@ public sealed class AppOrchestrator : IDisposable
|
||||
RunAsync(async () =>
|
||||
{
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||
|
||||
try
|
||||
var destination = DedupPath(GetDownloadDir(), fileName);
|
||||
File.Move(tempPath, destination);
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Saved to: {destination}"));
|
||||
|
||||
if (SafeOpenExtensions.Contains(Path.GetExtension(fileName)))
|
||||
{
|
||||
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}"));
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo(destination) { UseShellExecute = true };
|
||||
System.Diagnostics.Process.Start(psi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to open file with default app: {Path}", destination);
|
||||
}
|
||||
}
|
||||
}, "Failed to download file");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the download folder. With no argument, opens the OS-native folder picker; if that
|
||||
/// isn't available (headless, missing tool), tells the user to pass a path instead. With an
|
||||
/// argument, sets that path directly (the fallback for machines with no native picker).
|
||||
/// </summary>
|
||||
private Task HandleCmdSetDownloadPath(string args)
|
||||
{
|
||||
var current = _config.DownloadPath ?? GetDownloadDir();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(args))
|
||||
{
|
||||
SetDownloadPath(args.Trim());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
var result = await NativeFolderPicker.PickFolderAsync(current);
|
||||
InvokeUI(() =>
|
||||
{
|
||||
switch (result.Outcome)
|
||||
{
|
||||
case PickerOutcome.Chosen when result.Path is not null:
|
||||
SetDownloadPath(result.Path);
|
||||
break;
|
||||
case PickerOutcome.Cancelled:
|
||||
_messageManager.AddSystemMessage(_mainWindow.CurrentChannel, "Download folder unchanged.");
|
||||
break;
|
||||
case PickerOutcome.Unavailable:
|
||||
_messageManager.AddSystemMessage(_mainWindow.CurrentChannel,
|
||||
$"No native folder picker here. Current download folder: {current}\nSet one with: /downloadpath <path>");
|
||||
break;
|
||||
}
|
||||
});
|
||||
}, "Failed to open folder picker");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void SetDownloadPath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError($"Can't use that folder: {ex.Message}"));
|
||||
return;
|
||||
}
|
||||
|
||||
_config.DownloadPath = path;
|
||||
ConfigManager.Save(_config);
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Download folder set to: {path}"));
|
||||
}
|
||||
|
||||
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 void FetchAndUpdateOnlineUsers()
|
||||
@@ -914,6 +1608,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
try
|
||||
{
|
||||
var users = await _conn.GetOnlineUsersAsync(channel);
|
||||
lock (_channelUsersLock) _channelUsers[channel] = users;
|
||||
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -13,7 +13,11 @@ public class CommandHandler
|
||||
public event Func<string, string?, Task>? OnSendFile;
|
||||
public event Func<string?, Task>? OnOpenProfile;
|
||||
public event Func<Task>? OnOpenServers;
|
||||
public event Func<string, Task>? OnJoinChannel;
|
||||
public event Func<string, string?, Task>? OnJoinChannel;
|
||||
public event Func<string, string, Task>? OnChangeRoomPassword;
|
||||
public event Func<Task>? OnClearAttachments;
|
||||
public event Func<string, Task>? OnSetDownloadPath;
|
||||
public event Func<string, Task>? OnSetAsciiSize;
|
||||
public event Func<Task>? OnLeaveChannel;
|
||||
public event Func<string, Task>? OnSetTopic;
|
||||
public event Func<Task>? OnListUsers;
|
||||
@@ -47,10 +51,14 @@ public class CommandHandler
|
||||
"color" => await HandleColor(args),
|
||||
"theme" => await HandleTheme(args),
|
||||
"send" => await HandleSend(args),
|
||||
"clear" => await HandleClear(),
|
||||
"size" or "asciisize" => await HandleAsciiSize(args),
|
||||
"downloadpath" or "downloads" => await HandleDownloadPath(args),
|
||||
"profile" => await HandleProfile(args),
|
||||
"avatar" => await HandleAvatar(args),
|
||||
"servers" => await HandleServers(),
|
||||
"join" => await HandleJoin(args),
|
||||
"passwd" => await HandlePasswd(args),
|
||||
"leave" => await HandleLeave(),
|
||||
"topic" => await HandleTopic(args),
|
||||
"users" => await HandleUsers(),
|
||||
@@ -126,7 +134,7 @@ public class CommandHandler
|
||||
private async Task<CommandResult> HandleTheme(string args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(args))
|
||||
return new CommandResult(true, "Usage: /theme <name> (Default, Dark, Light, Hacker, Solarized)", IsError: true);
|
||||
return new CommandResult(true, "Usage: /theme <name> — pick one from the User menu's theme list (e.g. Default, Transparent, TransparentLight, Hacker)", IsError: true);
|
||||
|
||||
if (OnSetTheme is not null)
|
||||
await OnSetTheme(args.Trim());
|
||||
@@ -163,6 +171,29 @@ public class CommandHandler
|
||||
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleClear()
|
||||
{
|
||||
if (OnClearAttachments is not null)
|
||||
await OnClearAttachments();
|
||||
return new CommandResult(true, "Cleared staged attachments.");
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleAsciiSize(string args)
|
||||
{
|
||||
// No argument → open the size picker; an argument (s/m/l or small/medium/large) sets it.
|
||||
if (OnSetAsciiSize is not null)
|
||||
await OnSetAsciiSize(args.Trim());
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleDownloadPath(string args)
|
||||
{
|
||||
// No argument → open the native folder picker; an argument sets the path directly.
|
||||
if (OnSetDownloadPath is not null)
|
||||
await OnSetDownloadPath(args.Trim());
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleProfile(string args)
|
||||
{
|
||||
var username = string.IsNullOrWhiteSpace(args) ? null : args.Trim();
|
||||
@@ -193,11 +224,28 @@ public class CommandHandler
|
||||
private async Task<CommandResult> HandleJoin(string args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(args))
|
||||
return new CommandResult(true, "Usage: /join <channel>", IsError: true);
|
||||
return new CommandResult(true, "Usage: /join <channel> [password]", IsError: true);
|
||||
|
||||
var parts = args.Trim().Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||
var channel = parts[0].TrimStart('#');
|
||||
var password = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : null;
|
||||
|
||||
var channel = args.Trim().TrimStart('#');
|
||||
if (OnJoinChannel is not null)
|
||||
await OnJoinChannel(channel);
|
||||
await OnJoinChannel(channel, password);
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandlePasswd(string args)
|
||||
{
|
||||
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2)
|
||||
return new CommandResult(true, "Usage: /passwd <old passphrase> <new passphrase> — changes the current encrypted channel's passphrase", IsError: true);
|
||||
|
||||
if (parts[1].Length < 3)
|
||||
return new CommandResult(true, "New passphrase must be at least 3 characters.", IsError: true);
|
||||
|
||||
if (OnChangeRoomPassword is not null)
|
||||
await OnChangeRoomPassword(parts[0], parts[1]);
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
@@ -339,11 +387,19 @@ public class CommandHandler
|
||||
/nick <name> - Set display name
|
||||
/color <#hex> - Set nickname color
|
||||
/theme <name> - Switch theme
|
||||
/send <filepath or URL> [-s|-m|-l] - Send file/image/audio (size flag for images)
|
||||
/send <filepath> [-s|-m|-l] - Stage a file to attach (Enter sends with your text)
|
||||
/send <URL> [-s|-m|-l] - Send an image URL immediately
|
||||
/clear - Drop all staged attachments
|
||||
/size [s|m|l] - ASCII art size for attached images (no arg = picker)
|
||||
(Tip: copy a file and press Ctrl+V, or drag a file onto the window, to attach it.)
|
||||
(Tip: right-click a message for actions — delete, save/download/play attachment,
|
||||
mention, view profile, copy. Or press F6 to pick a message, then Delete.)
|
||||
/downloadpath [path] - Set download folder (no path = native folder picker)
|
||||
/avatar <URL or filepath> - Set your avatar
|
||||
/profile [username] - View a profile
|
||||
/servers - Open saved servers
|
||||
/join <channel> - Join a channel
|
||||
/join <channel> [password] - Join a channel (password if protected)
|
||||
/passwd <old> <new> - Change current encrypted channel's passphrase
|
||||
/leave - Leave current channel
|
||||
/topic <text> - Set channel topic
|
||||
/users - List online users
|
||||
|
||||
@@ -6,6 +6,18 @@ public class ClientConfig
|
||||
public AccountPreset DefaultPreset { get; set; } = new();
|
||||
public string ActiveTheme { get; set; } = "Default";
|
||||
public NotificationConfig Notifications { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Folder where downloaded attachments and saved images are written. When null, the
|
||||
/// OS Downloads folder is used. Set via the native folder picker or <c>/downloadpath</c>.
|
||||
/// </summary>
|
||||
public string? DownloadPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ASCII-art rendering size for images you attach: "s" (40×40), "m" (80×80), or "l" (120×120).
|
||||
/// Applies to copy-paste/drag-drop attachments, which have no per-file size flag.
|
||||
/// </summary>
|
||||
public string DefaultAsciiSize { get; set; } = "m";
|
||||
}
|
||||
|
||||
public class NotificationConfig
|
||||
@@ -23,6 +35,13 @@ public class SavedServer
|
||||
public string? RefreshToken { get; set; }
|
||||
public bool RememberMe { get; set; }
|
||||
public DateTimeOffset LastConnected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached room content keys for end-to-end encrypted channels on this server,
|
||||
/// keyed by channel name (base64). Like RefreshToken, these live only on the
|
||||
/// user's machine — the server never sees them.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> ChannelKeys { get; set; } = [];
|
||||
}
|
||||
|
||||
public class AccountPreset
|
||||
|
||||
@@ -5,15 +5,14 @@
|
||||
</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.Extensions.Configuration.Json" Version="10.0.3" />
|
||||
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
|
||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.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) -->
|
||||
<ProjectReference Include="..\Terminal.Gui\Terminal.Gui\Terminal.Gui.csproj" />
|
||||
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5043" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,12 +1,59 @@
|
||||
using EchoHub.Client;
|
||||
using EchoHub.Client.Config;
|
||||
using EchoHub.Client.Services;
|
||||
using EchoHub.Client.Themes;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Serilog;
|
||||
using Serilog.Settings.Configuration;
|
||||
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");
|
||||
if (!File.Exists(appSettingsPath))
|
||||
{
|
||||
@@ -25,12 +72,50 @@ var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
|
||||
.Build();
|
||||
|
||||
// Explicit sink-assembly reference is required under PublishSingleFile — the default
|
||||
// AssemblyFinder scans for Serilog.Sinks.*.dll on disk, which don't exist in a bundled exe.
|
||||
var serilogOptions = new ConfigurationReaderOptions(typeof(FileLoggerConfigurationExtensions).Assembly);
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(configuration)
|
||||
.ReadFrom.Configuration(configuration, serilogOptions)
|
||||
.CreateLogger();
|
||||
|
||||
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
|
||||
{
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||
@@ -61,7 +61,7 @@ public sealed class ApiClient : IDisposable
|
||||
throw new InvalidOperationException("No refresh token available.");
|
||||
|
||||
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);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||
@@ -73,7 +73,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task<LoginResponse> LoginWithRefreshTokenAsync(string 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);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
|
||||
@@ -90,7 +90,7 @@ public sealed class ApiClient : IDisposable
|
||||
try
|
||||
{
|
||||
var request = new RefreshRequest(_refreshToken);
|
||||
await _http.PostAsJsonAsync("/api/auth/logout", request);
|
||||
using var response = await _http.PostAsJsonAsync("/api/auth/logout", request);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -131,7 +131,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task<List<ChannelDto>> GetChannelsAsync()
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedGetAsync("/api/channels");
|
||||
using var response = await AuthenticatedGetAsync("/api/channels");
|
||||
await EnsureSuccessAsync(response);
|
||||
var paginated = await response.Content.ReadFromJsonAsync<PaginatedResponse<ChannelDto>>();
|
||||
return paginated?.Items ?? [];
|
||||
@@ -146,7 +146,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task<string> GetEncryptionKeyAsync()
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedGetAsync("/api/server/encryption-key");
|
||||
using var response = await AuthenticatedGetAsync("/api/server/encryption-key");
|
||||
await EnsureSuccessAsync(response);
|
||||
var result = await response.Content.ReadFromJsonAsync<EncryptionKeyResponse>()
|
||||
?? 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)
|
||||
{
|
||||
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);
|
||||
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||
}
|
||||
@@ -164,7 +164,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PutAsJsonAsync("/api/users/profile", request));
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
|
||||
@@ -178,24 +178,42 @@ public sealed class ApiClient : IDisposable
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
||||
content.Add(streamContent, "file", fileName);
|
||||
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsync("/api/users/avatar", content));
|
||||
await EnsureSuccessAsync(response);
|
||||
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
|
||||
return result?.AvatarAscii;
|
||||
}
|
||||
|
||||
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null)
|
||||
/// <summary>
|
||||
/// Sends one message with optional text and one or more file attachments.
|
||||
/// For end-to-end encrypted channels each attachment carries a declared kind and a
|
||||
/// room-encrypted preview (empty when none); the caption is likewise room-encrypted.
|
||||
/// </summary>
|
||||
public async Task<MessageDto?> SendMessageWithAttachmentsAsync(
|
||||
string channelName, string content, IReadOnlyList<OutgoingAttachment> attachments, string? size = null)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
using var content = new MultipartFormDataContent();
|
||||
using var streamContent = new StreamContent(fileStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
||||
content.Add(streamContent, "file", fileName);
|
||||
using var form = new MultipartFormDataContent { { new StringContent(content), "content" } };
|
||||
|
||||
foreach (var att in attachments)
|
||||
{
|
||||
var streamContent = new StreamContent(att.Stream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(att.FileName));
|
||||
form.Add(streamContent, "file", att.FileName);
|
||||
|
||||
// Encrypted channels: one kind + preview per file, in the same order, to keep
|
||||
// the server's index alignment (empty preview string for non-images).
|
||||
if (att.DeclaredKind is not null)
|
||||
{
|
||||
form.Add(new StringContent(att.DeclaredKind), "kind");
|
||||
form.Add(new StringContent(att.EncryptedPreview ?? string.Empty), "preview");
|
||||
}
|
||||
}
|
||||
|
||||
var sizeQuery = size is not null ? $"?size={size}" : "";
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/messages{sizeQuery}", form));
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||
}
|
||||
@@ -205,7 +223,7 @@ public sealed class ApiClient : IDisposable
|
||||
EnsureAuthenticated();
|
||||
var request = new SendUrlRequest(url);
|
||||
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));
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<MessageDto>();
|
||||
@@ -214,7 +232,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedGetAsync(relativeUrl);
|
||||
using var response = await AuthenticatedGetAsync(relativeUrl);
|
||||
await EnsureSuccessAsync(response);
|
||||
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub");
|
||||
@@ -228,21 +246,45 @@ public sealed class ApiClient : IDisposable
|
||||
return tempPath;
|
||||
}
|
||||
|
||||
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
|
||||
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true,
|
||||
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var request = new CreateChannelRequest(name, topic, isPublic);
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
var request = new CreateChannelRequest(name, topic, isPublic, password, encryptionSalt, wrappedRoomKey);
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsJsonAsync("/api/channels", request));
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a channel's public crypto metadata (whether it's E2E-encrypted and its
|
||||
/// key-derivation salt). Returns null when the channel doesn't exist.
|
||||
/// </summary>
|
||||
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
using var response = await AuthenticatedGetAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/crypto");
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<ChannelCryptoDto>();
|
||||
}
|
||||
|
||||
public async Task<ChannelDto?> RekeyChannelAsync(string channelName, RekeyChannelRequest request)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/rekey", request));
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||
}
|
||||
|
||||
public async Task<ChannelDto?> UpdateChannelTopicAsync(string channelName, string? topic)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var request = new UpdateTopicRequest(topic);
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PutAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/topic", request));
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||
@@ -251,7 +293,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task DeleteChannelAsync(string channelName)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.DeleteAsync($"/api/channels/{Uri.EscapeDataString(channelName)}"));
|
||||
await EnsureSuccessAsync(response);
|
||||
}
|
||||
@@ -261,7 +303,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task AssignRoleAsync(string username, ServerRole role)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
|
||||
await EnsureSuccessAsync(response);
|
||||
}
|
||||
@@ -269,7 +311,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task KickUserAsync(string username, string? reason = null)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
|
||||
await EnsureSuccessAsync(response);
|
||||
}
|
||||
@@ -277,7 +319,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task BanUserAsync(string username, string? reason = null)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
|
||||
await EnsureSuccessAsync(response);
|
||||
}
|
||||
@@ -285,7 +327,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task UnbanUserAsync(string username)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new { }));
|
||||
await EnsureSuccessAsync(response);
|
||||
}
|
||||
@@ -293,7 +335,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
|
||||
await EnsureSuccessAsync(response);
|
||||
}
|
||||
@@ -301,7 +343,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task UnmuteUserAsync(string username)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new { }));
|
||||
await EnsureSuccessAsync(response);
|
||||
}
|
||||
@@ -309,7 +351,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task DeleteMessageAsync(Guid messageId)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
|
||||
await EnsureSuccessAsync(response);
|
||||
}
|
||||
@@ -317,7 +359,7 @@ public sealed class ApiClient : IDisposable
|
||||
public async Task NukeChannelAsync(string channelName)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedRequestAsync(() =>
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
|
||||
await EnsureSuccessAsync(response);
|
||||
}
|
||||
@@ -333,6 +375,7 @@ public sealed class ApiClient : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Performs a GET request with automatic token refresh on 401.
|
||||
/// Caller is responsible for disposing the returned response.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> AuthenticatedGetAsync(string url)
|
||||
{
|
||||
@@ -343,7 +386,9 @@ public sealed class ApiClient : IDisposable
|
||||
try
|
||||
{
|
||||
await RefreshTokenAsync();
|
||||
response = await _http.GetAsync(url);
|
||||
var retryResponse = await _http.GetAsync(url);
|
||||
response.Dispose();
|
||||
response = retryResponse;
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -356,6 +401,7 @@ public sealed class ApiClient : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Performs a request with automatic token refresh on 401.
|
||||
/// Caller is responsible for disposing the returned response.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> AuthenticatedRequestAsync(Func<Task<HttpResponseMessage>> requestFactory)
|
||||
{
|
||||
@@ -366,7 +412,9 @@ public sealed class ApiClient : IDisposable
|
||||
try
|
||||
{
|
||||
await RefreshTokenAsync();
|
||||
response = await requestFactory();
|
||||
var retryResponse = await requestFactory();
|
||||
response.Dispose();
|
||||
response = retryResponse;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace EchoHub.Client.Services;
|
||||
public class AudioPlaybackService
|
||||
{
|
||||
private readonly Player _player = new();
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public bool IsPlaying => _player.Playing;
|
||||
public bool IsPaused => _player.Paused;
|
||||
@@ -19,6 +20,7 @@ public class AudioPlaybackService
|
||||
|
||||
public async Task PlayAsync(string filePath)
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_player.Playing)
|
||||
@@ -30,10 +32,15 @@ public class AudioPlaybackService
|
||||
{
|
||||
Log.Warning(ex, "Failed to play audio file: {Path}", filePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PauseAsync()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_player.Playing && !_player.Paused)
|
||||
@@ -43,10 +50,15 @@ public class AudioPlaybackService
|
||||
{
|
||||
Log.Warning(ex, "Failed to pause audio playback");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ResumeAsync()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_player.Paused)
|
||||
@@ -56,23 +68,33 @@ public class AudioPlaybackService
|
||||
{
|
||||
Log.Warning(ex, "Failed to resume audio playback");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_player.Playing)
|
||||
if (_player.Playing || _player.Paused)
|
||||
await _player.Stop();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to stop audio playback");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetVolumeAsync(byte volume)
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await _player.SetVolume(Math.Min(volume, (byte)100));
|
||||
@@ -81,5 +103,9 @@ public class AudioPlaybackService
|
||||
{
|
||||
Log.Warning(ex, "Failed to set audio volume");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using Serilog;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads file paths that live on the OS clipboard as a *file list* (e.g. after copying a file in
|
||||
/// Explorer/Finder/Nautilus), which terminals do not paste as text. Lets Ctrl+V attach a copied
|
||||
/// file directly instead of requiring the user to paste a raw path.
|
||||
/// </summary>
|
||||
public static class ClipboardFiles
|
||||
{
|
||||
public static bool TryGetFiles(out List<string> files)
|
||||
{
|
||||
files = [];
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return TryGetWindows(out files);
|
||||
if (OperatingSystem.IsLinux())
|
||||
return TryGetLinux(out files);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Reading files from the clipboard failed");
|
||||
}
|
||||
|
||||
// macOS and everything else: no file-list clipboard support (text paste still works).
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Windows: CF_HDROP via the Win32 clipboard ────────────────────────────
|
||||
|
||||
private const uint CfHdrop = 15;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static bool TryGetWindows(out List<string> files)
|
||||
{
|
||||
files = [];
|
||||
if (!IsClipboardFormatAvailable(CfHdrop))
|
||||
return false;
|
||||
|
||||
// The clipboard may briefly be held by another process; a few quick retries cover that.
|
||||
var opened = false;
|
||||
for (var attempt = 0; attempt < 5 && !opened; attempt++)
|
||||
opened = OpenClipboard(IntPtr.Zero);
|
||||
if (!opened)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var hDrop = GetClipboardData(CfHdrop);
|
||||
if (hDrop == IntPtr.Zero)
|
||||
return false;
|
||||
|
||||
var count = DragQueryFileW(hDrop, 0xFFFFFFFF, null, 0);
|
||||
for (uint i = 0; i < count; i++)
|
||||
{
|
||||
var len = DragQueryFileW(hDrop, i, null, 0);
|
||||
if (len == 0)
|
||||
continue;
|
||||
|
||||
var sb = new StringBuilder((int)len + 1);
|
||||
DragQueryFileW(hDrop, i, sb, (uint)sb.Capacity);
|
||||
var path = sb.ToString();
|
||||
if (File.Exists(path))
|
||||
files.Add(path);
|
||||
}
|
||||
|
||||
return files.Count > 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseClipboard();
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool OpenClipboard(IntPtr hWndNewOwner);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CloseClipboard();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool IsClipboardFormatAvailable(uint format);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GetClipboardData(uint uFormat);
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern uint DragQueryFileW(IntPtr hDrop, uint iFile, StringBuilder? lpszFile, uint cch);
|
||||
|
||||
// ── Linux: text/uri-list from the clipboard via xclip or wl-paste ─────────
|
||||
|
||||
[SupportedOSPlatform("linux")]
|
||||
private static bool TryGetLinux(out List<string> files)
|
||||
{
|
||||
files = [];
|
||||
|
||||
var output = RunForOutput("wl-paste", ["--type", "text/uri-list", "--no-newline"])
|
||||
?? RunForOutput("xclip", ["-selection", "clipboard", "-t", "text/uri-list", "-o"]);
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
return false;
|
||||
|
||||
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!line.StartsWith("file://", StringComparison.Ordinal))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
var path = new Uri(line).LocalPath;
|
||||
if (File.Exists(path))
|
||||
files.Add(path);
|
||||
}
|
||||
catch (UriFormatException) { /* skip malformed entry */ }
|
||||
}
|
||||
|
||||
return files.Count > 0;
|
||||
}
|
||||
|
||||
private static string? RunForOutput(string fileName, IEnumerable<string> args)
|
||||
{
|
||||
var psi = new ProcessStartInfo(fileName)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var arg in args)
|
||||
psi.ArgumentList.Add(arg);
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(psi);
|
||||
if (process is null)
|
||||
return null;
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(2000);
|
||||
return process.ExitCode == 0 ? output : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException)
|
||||
{
|
||||
return null; // tool not installed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
private EchoHubConnection? _connection;
|
||||
private ApiClient? _apiClient;
|
||||
private readonly ClientEncryptionService _encryption = new();
|
||||
private readonly RoomKeyStore _roomKeys = new();
|
||||
private readonly HashSet<string> _joinedChannels = [];
|
||||
|
||||
// ── Properties ────────────────────────────────────────────────────────
|
||||
@@ -31,11 +32,12 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
public bool IsConnected => _connection?.IsConnected == true;
|
||||
public bool IsAuthenticated => _apiClient is not null;
|
||||
public ApiClient? Api => _apiClient;
|
||||
public RoomKeyStore RoomKeys => _roomKeys;
|
||||
|
||||
// ── Events (forwarded from SignalR) ───────────────────────────────────
|
||||
|
||||
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<UserPresenceDto>? UserStatusChanged;
|
||||
public event Action<string, string, string?>? UserKicked;
|
||||
@@ -60,77 +62,84 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
_apiClient?.Dispose();
|
||||
_apiClient = new ApiClient(info.ServerUrl);
|
||||
|
||||
onStatus("Authenticating...");
|
||||
|
||||
LoginResponse loginResponse;
|
||||
|
||||
if (info.SavedRefreshToken is not null)
|
||||
try
|
||||
{
|
||||
try
|
||||
onStatus("Authenticating...");
|
||||
|
||||
LoginResponse loginResponse;
|
||||
|
||||
if (info.SavedRefreshToken is not null)
|
||||
{
|
||||
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken);
|
||||
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();
|
||||
|
||||
_roomKeys.LoadForServer(info.ServerUrl);
|
||||
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption, _roomKeys);
|
||||
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
|
||||
{
|
||||
_apiClient.Dispose();
|
||||
_apiClient = null;
|
||||
throw; // Caller handles saved-session expiry
|
||||
// History might not be available
|
||||
}
|
||||
}
|
||||
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);
|
||||
return new ConnectResult(loginResponse, channels, history);
|
||||
}
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
@@ -150,6 +159,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
_apiClient?.Dispose();
|
||||
_apiClient = null;
|
||||
_joinedChannels.Clear();
|
||||
_roomKeys.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -163,11 +173,21 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
|
||||
// ── Channel Operations ────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||
public async Task<JoinOutcome> JoinChannelAsync(string channelName, string? password = null)
|
||||
{
|
||||
if (_connection is null) throw new InvalidOperationException("Not connected");
|
||||
_joinedChannels.Add(channelName);
|
||||
return await _connection.JoinChannelAsync(channelName);
|
||||
try
|
||||
{
|
||||
var outcome = await _connection.JoinChannelAsync(channelName, password);
|
||||
_joinedChannels.Add(channelName);
|
||||
return outcome;
|
||||
}
|
||||
catch (ChannelPasswordRequiredException)
|
||||
{
|
||||
// Not actually joined — don't track, or reconnects would retry a doomed join
|
||||
_joinedChannels.Remove(channelName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LeaveChannelAsync(string channelName)
|
||||
@@ -190,8 +210,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
_connection?.SendMessageAsync(channel, content)
|
||||
?? throw new InvalidOperationException("Not connected");
|
||||
|
||||
public Task<List<MessageDto>> GetHistoryAsync(string channel) =>
|
||||
_connection?.GetHistoryAsync(channel)
|
||||
public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
|
||||
_connection?.GetHistoryAsync(channel, count, offset)
|
||||
?? throw new InvalidOperationException("Not connected");
|
||||
|
||||
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) =>
|
||||
@@ -228,7 +248,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
private void WireConnectionEvents(EchoHubConnection connection)
|
||||
{
|
||||
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.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p);
|
||||
connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason);
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Core.Security;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of joining a channel: decrypted history plus, for end-to-end encrypted
|
||||
/// channels, the key envelope needed to unlock the room content key.
|
||||
/// </summary>
|
||||
public sealed record JoinOutcome(List<MessageDto> History, string? EncryptionSalt, string? WrappedRoomKey);
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when joining a channel fails because a password is required or incorrect.
|
||||
/// The UI catches this to prompt the user and retry.
|
||||
/// </summary>
|
||||
public sealed class ChannelPasswordRequiredException : Exception
|
||||
{
|
||||
public string ChannelName { get; }
|
||||
|
||||
public ChannelPasswordRequiredException(string channelName, string message) : base(message)
|
||||
{
|
||||
ChannelName = channelName;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EchoHubConnection : IAsyncDisposable
|
||||
{
|
||||
public const string LockedMessagePlaceholder =
|
||||
"[encrypted — rejoin this channel with its passphrase to unlock]";
|
||||
|
||||
private readonly HubConnection _connection;
|
||||
private readonly ClientEncryptionService _encryption;
|
||||
private readonly RoomKeyStore _roomKeys;
|
||||
|
||||
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<ChannelDto>? OnChannelUpdated;
|
||||
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
||||
@@ -26,9 +51,10 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
|
||||
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
||||
|
||||
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption)
|
||||
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption, RoomKeyStore roomKeys)
|
||||
{
|
||||
_encryption = encryption;
|
||||
_roomKeys = roomKeys;
|
||||
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
||||
|
||||
_connection = new HubConnectionBuilder()
|
||||
@@ -65,14 +91,12 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
{
|
||||
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
|
||||
{
|
||||
// Decrypt message content received from server
|
||||
var decrypted = message with { Content = _encryption.Decrypt(message.Content) };
|
||||
OnMessageReceived?.Invoke(decrypted);
|
||||
OnMessageReceived?.Invoke(DecryptMessage(message));
|
||||
});
|
||||
|
||||
_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) =>
|
||||
@@ -134,10 +158,16 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
OnConnectionStateChanged?.Invoke("Disconnected");
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||
public async Task<JoinOutcome> JoinChannelAsync(string channelName, string? password = null)
|
||||
{
|
||||
var messages = await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
|
||||
return DecryptMessages(messages);
|
||||
var result = await _connection.InvokeAsync<JoinChannelResult>("JoinChannel", channelName, password);
|
||||
if (!result.Success)
|
||||
{
|
||||
if (result.PasswordRequired)
|
||||
throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected.");
|
||||
throw new InvalidOperationException(result.Error ?? "Failed to join channel.");
|
||||
}
|
||||
return new JoinOutcome(DecryptMessages(result.History), result.EncryptionSalt, result.WrappedRoomKey);
|
||||
}
|
||||
|
||||
public async Task LeaveChannelAsync(string channelName)
|
||||
@@ -147,14 +177,17 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
|
||||
public async Task SendMessageAsync(string channelName, string content)
|
||||
{
|
||||
// Encrypt content before sending to server
|
||||
// Room layer first (end-to-end, server can't read), then transport encryption
|
||||
if (_roomKeys.TryGetKey(channelName, out var roomKey))
|
||||
content = RoomCrypto.EncryptText(content, roomKey);
|
||||
|
||||
var encrypted = _encryption.Encrypt(content);
|
||||
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
|
||||
{
|
||||
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
|
||||
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count, offset);
|
||||
return DecryptMessages(messages);
|
||||
}
|
||||
|
||||
@@ -170,7 +203,45 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
|
||||
private List<MessageDto> DecryptMessages(List<MessageDto> messages)
|
||||
{
|
||||
return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList();
|
||||
return messages.Select(DecryptMessage).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips the transport encryption, then the room layer for E2E channels, from the
|
||||
/// message content and every attachment preview. Without the room key the content is
|
||||
/// replaced by a locked placeholder — re-fetch history after unlocking to render it.
|
||||
/// </summary>
|
||||
private MessageDto DecryptMessage(MessageDto message)
|
||||
{
|
||||
_roomKeys.TryGetKey(message.ChannelName, out var roomKey);
|
||||
|
||||
var content = DecryptField(message.Content, roomKey) ?? LockedMessagePlaceholder;
|
||||
|
||||
List<AttachmentDto>? attachments = null;
|
||||
if (message.Attachments is { Count: > 0 })
|
||||
{
|
||||
attachments = message.Attachments
|
||||
.Select(a => a with { AsciiPreview = a.AsciiPreview is null ? null : DecryptField(a.AsciiPreview, roomKey) })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return message with { Content = content, Attachments = attachments };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts one field: strips transport encryption, then the room layer if it is room
|
||||
/// ciphertext. Returns null when it is room ciphertext but the room key is missing/wrong.
|
||||
/// </summary>
|
||||
private string? DecryptField(string value, byte[]? roomKey)
|
||||
{
|
||||
var plain = _encryption.Decrypt(value);
|
||||
if (!RoomCrypto.IsRoomCiphertext(plain))
|
||||
return plain;
|
||||
|
||||
if (roomKey is not null && RoomCrypto.TryDecryptText(plain, roomKey, out var decrypted))
|
||||
return decrypted;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Serilog;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
public enum PickerOutcome
|
||||
{
|
||||
/// <summary>The user picked a folder (<see cref="FolderPickResult.Path"/> is set).</summary>
|
||||
Chosen,
|
||||
|
||||
/// <summary>The native dialog ran but the user cancelled it.</summary>
|
||||
Cancelled,
|
||||
|
||||
/// <summary>No native picker is available on this machine (headless, missing tool, etc.).</summary>
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
public sealed record FolderPickResult(PickerOutcome Outcome, string? Path);
|
||||
|
||||
/// <summary>
|
||||
/// Opens the OS-native folder chooser (Windows Explorer, macOS Finder, Linux GTK/KDE) by shelling
|
||||
/// out, so the TUI doesn't need a GUI toolkit reference. Returns <see cref="PickerOutcome.Unavailable"/>
|
||||
/// when no native dialog can run, so callers can fall back to a configured path.
|
||||
/// </summary>
|
||||
public static class NativeFolderPicker
|
||||
{
|
||||
private const string Title = "Choose your EchoHub download folder";
|
||||
|
||||
public static async Task<FolderPickResult> PickFolderAsync(string? initialDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return await PickWindowsAsync(initialDir);
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
return await PickMacAsync();
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
return await PickLinuxAsync(initialDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Native folder picker failed");
|
||||
}
|
||||
|
||||
return new FolderPickResult(PickerOutcome.Unavailable, null);
|
||||
}
|
||||
|
||||
private static async Task<FolderPickResult> PickWindowsAsync(string? initialDir)
|
||||
{
|
||||
var safeInit = (initialDir ?? string.Empty).Replace("'", "''");
|
||||
var script = $$"""
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$d = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||
$d.Description = '{{Title}}'
|
||||
$d.ShowNewFolderButton = $true
|
||||
$d.SelectedPath = '{{safeInit}}'
|
||||
if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }
|
||||
""";
|
||||
|
||||
// -EncodedCommand avoids all quoting issues; FolderBrowserDialog needs an STA thread.
|
||||
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script));
|
||||
var (started, _, stdout) = await RunAsync("powershell.exe",
|
||||
["-STA", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
|
||||
|
||||
if (!started)
|
||||
return new FolderPickResult(PickerOutcome.Unavailable, null);
|
||||
return string.IsNullOrWhiteSpace(stdout)
|
||||
? new FolderPickResult(PickerOutcome.Cancelled, null)
|
||||
: new FolderPickResult(PickerOutcome.Chosen, stdout);
|
||||
}
|
||||
|
||||
private static async Task<FolderPickResult> PickMacAsync()
|
||||
{
|
||||
var (started, exit, stdout) = await RunAsync("osascript",
|
||||
["-e", $"POSIX path of (choose folder with prompt \"{Title}\")"]);
|
||||
|
||||
if (!started)
|
||||
return new FolderPickResult(PickerOutcome.Unavailable, null);
|
||||
return exit == 0 && !string.IsNullOrWhiteSpace(stdout)
|
||||
? new FolderPickResult(PickerOutcome.Chosen, stdout)
|
||||
: new FolderPickResult(PickerOutcome.Cancelled, null);
|
||||
}
|
||||
|
||||
private static async Task<FolderPickResult> PickLinuxAsync(string? initialDir)
|
||||
{
|
||||
// No graphical session → no native picker.
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY"))
|
||||
&& string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")))
|
||||
return new FolderPickResult(PickerOutcome.Unavailable, null);
|
||||
|
||||
var zenityArgs = new List<string> { "--file-selection", "--directory", $"--title={Title}" };
|
||||
if (!string.IsNullOrWhiteSpace(initialDir))
|
||||
zenityArgs.Add($"--filename={initialDir!.TrimEnd('/')}/");
|
||||
|
||||
var (zStarted, zExit, zOut) = await RunAsync("zenity", zenityArgs);
|
||||
if (zStarted)
|
||||
return zExit == 0 && !string.IsNullOrWhiteSpace(zOut)
|
||||
? new FolderPickResult(PickerOutcome.Chosen, zOut)
|
||||
: new FolderPickResult(PickerOutcome.Cancelled, null);
|
||||
|
||||
var (kStarted, kExit, kOut) = await RunAsync("kdialog",
|
||||
["--getexistingdirectory", string.IsNullOrWhiteSpace(initialDir) ? "." : initialDir!]);
|
||||
if (kStarted)
|
||||
return kExit == 0 && !string.IsNullOrWhiteSpace(kOut)
|
||||
? new FolderPickResult(PickerOutcome.Chosen, kOut)
|
||||
: new FolderPickResult(PickerOutcome.Cancelled, null);
|
||||
|
||||
return new FolderPickResult(PickerOutcome.Unavailable, null);
|
||||
}
|
||||
|
||||
private static async Task<(bool Started, int ExitCode, string StdOut)> RunAsync(string fileName, IEnumerable<string> args)
|
||||
{
|
||||
var psi = new ProcessStartInfo(fileName)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var arg in args)
|
||||
psi.ArgumentList.Add(arg);
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(psi);
|
||||
if (process is null)
|
||||
return (false, -1, string.Empty);
|
||||
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
return (true, process.ExitCode, stdout.Trim());
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException)
|
||||
{
|
||||
// Executable not found on PATH → treat as "no native picker".
|
||||
return (false, -1, string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@ namespace EchoHub.Client.Services;
|
||||
|
||||
public class NotificationSoundService
|
||||
{
|
||||
// Safety net: if PlaybackFinished never fires we don't want to block future notifications forever.
|
||||
private static readonly TimeSpan PlaybackTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly Player _player = new();
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private readonly NotificationConfig _config;
|
||||
private string? _resolvedSoundPath;
|
||||
|
||||
@@ -41,18 +45,31 @@ public class NotificationSoundService
|
||||
|
||||
private async Task PlayInternal()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
|
||||
// _player.Play returns as soon as playback starts, so we wait on PlaybackFinished
|
||||
// to hold the lock for the duration of the sound. A one-shot handler + timeout
|
||||
// keeps the finally release robust: never-fires → timeout; fires twice → ignored
|
||||
// (TrySetResult); handler throws → caller's catch still runs finally.
|
||||
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
void OnFinished(object? s, EventArgs e) => completion.TrySetResult();
|
||||
_player.PlaybackFinished += OnFinished;
|
||||
|
||||
try
|
||||
{
|
||||
if (_player.Playing)
|
||||
await _player.Stop();
|
||||
|
||||
await _player.SetVolume(_config.Volume);
|
||||
await _player.Play(_resolvedSoundPath!);
|
||||
await Task.WhenAny(completion.Task, Task.Delay(PlaybackTimeout));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to play notification sound");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_player.PlaybackFinished -= OnFinished;
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void ResolveSoundPath()
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One file to upload as part of a message. For end-to-end encrypted channels the stream
|
||||
/// is already ciphertext, <see cref="DeclaredKind"/> is set (image/audio/file), and
|
||||
/// <see cref="EncryptedPreview"/> holds the room-encrypted ASCII art for images.
|
||||
/// For normal channels only <see cref="Stream"/> and <see cref="FileName"/> are set.
|
||||
/// </summary>
|
||||
public sealed record OutgoingAttachment(
|
||||
Stream Stream,
|
||||
string FileName,
|
||||
string? DeclaredKind = null,
|
||||
string? EncryptedPreview = null);
|
||||
@@ -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,109 @@
|
||||
using EchoHub.Client.Config;
|
||||
using Serilog;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Holds room content keys for end-to-end encrypted channels: in-memory for the
|
||||
/// active session, persisted per-server in the client config (like saved sessions)
|
||||
/// so users don't retype the passphrase every launch. Keys never leave this machine.
|
||||
/// </summary>
|
||||
public sealed class RoomKeyStore
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _keys = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _lock = new();
|
||||
private string? _serverUrl;
|
||||
|
||||
/// <summary>Binds the store to a server and loads that server's cached keys from config.</summary>
|
||||
public void LoadForServer(string serverUrl)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_serverUrl = serverUrl;
|
||||
_keys.Clear();
|
||||
|
||||
var server = FindServer(ConfigManager.Load(), serverUrl);
|
||||
if (server is null) return;
|
||||
|
||||
foreach (var (channel, base64) in server.ChannelKeys)
|
||||
{
|
||||
try
|
||||
{
|
||||
_keys[channel] = Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
Log.Warning("Ignoring malformed cached room key for #{Channel}", channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetKey(string channelName, out byte[] key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_keys.TryGetValue(channelName, out var k))
|
||||
{
|
||||
key = k;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
key = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool HasKey(string channelName) => TryGetKey(channelName, out _);
|
||||
|
||||
/// <summary>Stores a key for the session and persists it to the server's config entry.</summary>
|
||||
public void StoreKey(string channelName, byte[] key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_keys[channelName] = key;
|
||||
Persist(server => server.ChannelKeys[channelName] = Convert.ToBase64String(key));
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveKey(string channelName)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_keys.Remove(channelName);
|
||||
Persist(server => server.ChannelKeys.Remove(channelName));
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_keys.Clear();
|
||||
_serverUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Persist(Action<SavedServer> mutate)
|
||||
{
|
||||
if (_serverUrl is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var config = ConfigManager.Load();
|
||||
var server = FindServer(config, _serverUrl);
|
||||
if (server is null) return; // server not saved yet — key stays in-memory only
|
||||
|
||||
mutate(server);
|
||||
ConfigManager.Save(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to persist room key cache");
|
||||
}
|
||||
}
|
||||
|
||||
private static SavedServer? FindServer(ClientConfig config, string url) =>
|
||||
config.SavedServers.FirstOrDefault(s =>
|
||||
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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);
|
||||
|
||||
using (var archive = ZipFile.Open(BackupZipPath, ZipArchiveMode.Create))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(appDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(appDir, file);
|
||||
|
||||
// Skip log files to prevent locking errors with Serilog while zipping
|
||||
if (relativePath.StartsWith("logs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.StartsWith("logs" + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.EndsWith(".log", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize path separators for the zip archive format
|
||||
var entryName = relativePath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/');
|
||||
|
||||
try
|
||||
{
|
||||
archive.CreateEntryFromFile(file, entryName, CompressionLevel.Fastest);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Log.Warning(ex, "Skipped locked file {FileName} during backup calculation", relativePath);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
Log.Warning(ex, "Skipped inaccessible file {FileName} during backup calculation", relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 Terminal.Gui.App;
|
||||
using Terminal.Gui.Views;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
@@ -15,6 +16,8 @@ public sealed class UpdateChecker : IDisposable
|
||||
private readonly Updater _updater;
|
||||
private readonly IApplication _app;
|
||||
private UpdateProgressDialog? _progressDialog;
|
||||
private bool _manualCheck;
|
||||
|
||||
|
||||
public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
||||
|
||||
@@ -37,27 +40,64 @@ public sealed class UpdateChecker : IDisposable
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
public async Task CheckNowAsync()
|
||||
{
|
||||
_manualCheck = true;
|
||||
try
|
||||
{
|
||||
await _updater.CheckForUpdateAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_manualCheck = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnUpdateAvailable(string version, string changelogUrl)
|
||||
{
|
||||
Log.Information("Update available: v{Version}", version);
|
||||
|
||||
var confirmed = false;
|
||||
_app.Invoke(() =>
|
||||
{
|
||||
confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
|
||||
|
||||
var confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
|
||||
|
||||
if (confirmed)
|
||||
{
|
||||
_progressDialog = new UpdateProgressDialog(_app, version);
|
||||
|
||||
// Start the update; progress is reported via OnProgressChanged
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
// Create backup before the update starts
|
||||
try
|
||||
{
|
||||
_progressDialog?.UpdateProgress(0f, "Creating backup...");
|
||||
UpdateBackupService.CreateBackup();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to create pre-update backup");
|
||||
|
||||
var proceed = false;
|
||||
proceed = MessageBox.Query(
|
||||
_app,
|
||||
"Backup Warning",
|
||||
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
|
||||
"Continue", "Cancel") == 0;
|
||||
|
||||
if (!proceed)
|
||||
{
|
||||
_progressDialog?.Close();
|
||||
_progressDialog = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_progressDialog?.UpdateProgress(0f, "Downloading update...");
|
||||
await _updater.UpdateAsync();
|
||||
});
|
||||
|
||||
_progressDialog?.Show();
|
||||
_progressDialog.Show();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -83,15 +123,52 @@ public sealed class UpdateChecker : IDisposable
|
||||
private void OnNoUpdateAvailable()
|
||||
{
|
||||
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)
|
||||
{
|
||||
Log.Error(exception, "Update check failed");
|
||||
Log.Error(exception, "Update failed");
|
||||
_app.Invoke(() =>
|
||||
{
|
||||
_progressDialog?.Close();
|
||||
_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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -445,10 +445,44 @@ public static class ThemeManager
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly Theme TransparentLightTheme = new()
|
||||
{
|
||||
Name = "TransparentLight",
|
||||
Base = new ThemeColors
|
||||
{
|
||||
Foreground = "Black",
|
||||
Background = "None",
|
||||
FocusForeground = "Blue",
|
||||
FocusBackground = "None"
|
||||
},
|
||||
Menu = new ThemeColors
|
||||
{
|
||||
Foreground = "Black",
|
||||
Background = "None",
|
||||
FocusForeground = "Blue",
|
||||
FocusBackground = "None"
|
||||
},
|
||||
Dialog = new ThemeColors
|
||||
{
|
||||
Foreground = "Black",
|
||||
Background = "Gray",
|
||||
FocusForeground = "Blue",
|
||||
FocusBackground = "White"
|
||||
},
|
||||
Status = new ThemeColors
|
||||
{
|
||||
Foreground = "DarkGray",
|
||||
Background = "None",
|
||||
FocusForeground = "DarkGray",
|
||||
FocusBackground = "None"
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly List<Theme> BuiltInThemes =
|
||||
[
|
||||
DefaultTheme,
|
||||
TransparentTheme,
|
||||
TransparentLightTheme,
|
||||
ClassicTheme,
|
||||
LightTheme,
|
||||
HackerTheme,
|
||||
@@ -541,7 +575,14 @@ public static class ThemeManager
|
||||
Focus = focus,
|
||||
HotNormal = normal,
|
||||
HotFocus = focus,
|
||||
Disabled = normal
|
||||
Disabled = normal,
|
||||
|
||||
// TextView/TextField draw their editable area with the Editable/ReadOnly roles. If
|
||||
// left unset, Terminal.Gui derives an opaque background from Normal — which renders
|
||||
// as a solid box behind the input under transparent themes. Pin them to the theme's
|
||||
// own colors so the input matches its background (transparent stays transparent).
|
||||
Editable = normal,
|
||||
ReadOnly = normal
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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 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 EmbedTitleAttr = new(Color.White, 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);
|
||||
|
||||
/// <summary>
|
||||
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
||||
/// Non-mention text uses the provided default color.
|
||||
/// Split text around @mentions and #channels, giving each the appropriate accent color.
|
||||
/// Non-special text uses the provided default color.
|
||||
/// </summary>
|
||||
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
|
||||
{
|
||||
@@ -38,12 +39,44 @@ public static partial class ChatColors
|
||||
lastIndex = match.Index + match.Length;
|
||||
}
|
||||
|
||||
// Add remaining text after the last mention (or all text if no mentions found)
|
||||
if (lastIndex < text.Length)
|
||||
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;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"@[\w-]+")]
|
||||
// @mention — not preceded by a word char (avoids emails)
|
||||
[GeneratedRegex(@"(?<!\w)@[\w-]+")]
|
||||
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 EchoHub.Core.Models;
|
||||
using Terminal.Gui.Drawing;
|
||||
@@ -17,7 +18,10 @@ public partial class ChatLine
|
||||
public bool IsMention { get; set; }
|
||||
public string? AttachmentUrl { get; set; }
|
||||
public string? AttachmentFileName { get; set; }
|
||||
public MessageType? Type { get; set; }
|
||||
public AttachmentKind? AttachmentKind { 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)
|
||||
{
|
||||
@@ -42,59 +46,87 @@ public partial class ChatLine
|
||||
if (width <= 0 || TextLength <= width)
|
||||
return [this];
|
||||
|
||||
var results = new List<ChatLine>();
|
||||
var currentSegments = new List<ChatSegment>();
|
||||
int col = 0;
|
||||
|
||||
var tokens = new List<(string grapheme, Attribute? color)>();
|
||||
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;
|
||||
int chunkStart = 0;
|
||||
int charPos = 0;
|
||||
// First line uses the full width
|
||||
int col = firstLine ? 0 : continuationIndent;
|
||||
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);
|
||||
|
||||
if (col + graphemeCols > width)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
var graphemeCols = Math.Max(tokens[i].grapheme.GetColumns(), 1);
|
||||
if (col + graphemeCols > width) break;
|
||||
if (tokens[i].grapheme == " ") lastSpaceIdx = i;
|
||||
col += graphemeCols;
|
||||
charPos += grapheme.Length;
|
||||
}
|
||||
|
||||
if (chunkStart < text.Length)
|
||||
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
|
||||
int lineEnd, nextPos;
|
||||
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)
|
||||
results.Add(new ChatLine(currentSegments));
|
||||
if (results.Count == 0)
|
||||
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)
|
||||
{
|
||||
wrapped.AttachmentUrl = AttachmentUrl;
|
||||
wrapped.AttachmentFileName = AttachmentFileName;
|
||||
wrapped.Type = Type;
|
||||
wrapped.AttachmentKind = AttachmentKind;
|
||||
wrapped.MessageId = MessageId;
|
||||
wrapped.SenderUsername = SenderUsername;
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -65,6 +65,13 @@ public class ChatListSource : IListDataSource
|
||||
|
||||
var chatLine = _lines[item];
|
||||
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||
|
||||
// Highlight the selected row (whole width) while the list has focus — used by the
|
||||
// F6 selection flow and right-click. The custom source must draw this itself.
|
||||
var focusAttr = selected && listView.HasFocus
|
||||
? listView.GetAttributeForRole(VisualRole.Focus)
|
||||
: (Attribute?)null;
|
||||
|
||||
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
|
||||
|
||||
int charPos = 0;
|
||||
@@ -72,11 +79,19 @@ public class ChatListSource : IListDataSource
|
||||
|
||||
foreach (var segment in chatLine.Segments)
|
||||
{
|
||||
var attr = segment.Color ?? normalAttr;
|
||||
if (attr.Background == Color.None)
|
||||
attr = attr with { Background = normalAttr.Background };
|
||||
if (mentionBg.HasValue)
|
||||
attr = attr with { Background = mentionBg.Value };
|
||||
Attribute attr;
|
||||
if (focusAttr is { } focus)
|
||||
{
|
||||
attr = focus;
|
||||
}
|
||||
else
|
||||
{
|
||||
attr = segment.Color ?? normalAttr;
|
||||
if (attr.Background == Color.None)
|
||||
attr = attr with { Background = normalAttr.Background };
|
||||
if (mentionBg.HasValue)
|
||||
attr = attr with { Background = mentionBg.Value };
|
||||
}
|
||||
listView.SetAttribute(attr);
|
||||
|
||||
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
|
||||
@@ -91,7 +106,8 @@ public class ChatListSource : IListDataSource
|
||||
}
|
||||
}
|
||||
|
||||
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
|
||||
var fillAttr = focusAttr
|
||||
?? (mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr);
|
||||
listView.SetAttribute(fillAttr);
|
||||
for (int i = drawnChars; i < width; i++)
|
||||
listView.AddStr(" ");
|
||||
|
||||
@@ -101,7 +101,7 @@ public sealed class ChatMessageManager
|
||||
_channelMessages[channelName] = messages;
|
||||
}
|
||||
|
||||
var time = DateTimeOffset.Now.ToString("HH:mm");
|
||||
var time = FormatDateTime(DateTimeOffset.Now);
|
||||
var textLines = text.Split('\n');
|
||||
|
||||
messages.Add(new ChatLine(
|
||||
@@ -130,7 +130,7 @@ public sealed class ChatMessageManager
|
||||
/// </summary>
|
||||
public void AddStatusMessage(string channelName, string username, string status)
|
||||
{
|
||||
var time = DateTimeOffset.Now.ToString("HH:mm");
|
||||
var time = FormatDateTime(DateTimeOffset.Now);
|
||||
var segments = new List<ChatSegment>
|
||||
{
|
||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||
@@ -186,6 +186,39 @@ public sealed class ChatMessageManager
|
||||
MessagesChanged?.Invoke(channelName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepend older messages at the front of a channel's buffer, skipping any that are already present.
|
||||
/// Fires <see cref="HistoryPrepended"/> when new lines are actually inserted.
|
||||
/// </summary>
|
||||
public void PrependHistory(string channelName, List<MessageDto> olderMessages)
|
||||
{
|
||||
if (!_channelMessages.TryGetValue(channelName, out var existing))
|
||||
return;
|
||||
|
||||
var existingIds = existing
|
||||
.Where(l => l.MessageId.HasValue)
|
||||
.Select(l => l.MessageId!.Value)
|
||||
.ToHashSet();
|
||||
|
||||
var newLines = olderMessages
|
||||
.Where(m => !existingIds.Contains(m.Id))
|
||||
.SelectMany(FormatMessage)
|
||||
.ToList();
|
||||
|
||||
if (newLines.Count == 0)
|
||||
return;
|
||||
|
||||
existing.InsertRange(0, newLines);
|
||||
|
||||
if (channelName == _currentChannel)
|
||||
HistoryPrepended?.Invoke(channelName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fired after older messages are prepended to a channel's buffer. Parameter is the channel name.
|
||||
/// </summary>
|
||||
public event Action<string>? HistoryPrepended;
|
||||
|
||||
/// <summary>
|
||||
/// Reset all message state (used on disconnect).
|
||||
/// </summary>
|
||||
@@ -201,77 +234,90 @@ public sealed class ChatMessageManager
|
||||
|
||||
private List<ChatLine> FormatMessage(MessageDto message)
|
||||
{
|
||||
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
||||
var time = FormatDateTime(message.SentAt);
|
||||
var senderName = message.SenderUsername + ":";
|
||||
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
||||
|
||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
||||
var pad = new string(' ', 7);
|
||||
|
||||
var lines = new List<ChatLine>();
|
||||
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
|
||||
var attachments = message.Attachments ?? [];
|
||||
|
||||
switch (message.Type)
|
||||
// Header line: caption text, or a summary when the message is attachments-only
|
||||
if (hasContent)
|
||||
{
|
||||
case MessageType.Image:
|
||||
lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
|
||||
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||
{
|
||||
foreach (var artLine in message.Content.Split('\n'))
|
||||
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||
var contentLines = displayContent.Split('\n');
|
||||
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {contentLines[0].TrimEnd('\r')}"));
|
||||
for (int i = 1; i < contentLines.Length; i++)
|
||||
lines.Add(new ChatLine(ChatColors.SplitMentions($"{indent}{contentLines[i].TrimEnd('\r')}")));
|
||||
}
|
||||
else
|
||||
{
|
||||
var summary = attachments.Count switch
|
||||
{
|
||||
0 => " ",
|
||||
1 => $" [{attachments[0].Kind.ToString().ToLowerInvariant()}]",
|
||||
_ => $" [{attachments.Count} attachments]",
|
||||
};
|
||||
lines.Add(BuildChatLine(time, senderName, senderColor, summary));
|
||||
}
|
||||
|
||||
foreach (var l in lines)
|
||||
l.ContinuationIndent = indent.Length;
|
||||
|
||||
// One block per attachment
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
switch (attachment.Kind)
|
||||
{
|
||||
case Core.Models.AttachmentKind.Image:
|
||||
if (!string.IsNullOrWhiteSpace(attachment.AsciiPreview))
|
||||
{
|
||||
var trimmed = artLine.TrimEnd('\r');
|
||||
if (ChatLine.HasColorTags(trimmed))
|
||||
lines.Add(ChatLine.FromColoredText(" " + trimmed));
|
||||
else
|
||||
lines.Add(new ChatLine($" {trimmed}"));
|
||||
foreach (var artLine in attachment.AsciiPreview.Split('\n'))
|
||||
{
|
||||
var trimmed = artLine.TrimEnd('\r');
|
||||
lines.Add(ChatLine.HasColorTags(trimmed)
|
||||
? ChatLine.FromColoredText(pad + trimmed)
|
||||
: new ChatLine($"{pad}{trimmed}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
lines.Add(AttachmentActionLine(pad,
|
||||
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
|
||||
ChatColors.FileAttr, attachment));
|
||||
break;
|
||||
|
||||
case MessageType.Audio:
|
||||
var audioName = message.AttachmentFileName ?? "unknown";
|
||||
var audioSize = FormatFileSize(message.AttachmentFileSize);
|
||||
var audioLine = BuildChatLineColored(time, senderName, senderColor,
|
||||
$" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
|
||||
audioLine.AttachmentUrl = message.AttachmentUrl;
|
||||
audioLine.AttachmentFileName = audioName;
|
||||
audioLine.Type = MessageType.Audio;
|
||||
lines.Add(audioLine);
|
||||
break;
|
||||
case Core.Models.AttachmentKind.Audio:
|
||||
lines.Add(AttachmentActionLine(pad,
|
||||
$"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||
ChatColors.AudioAttr, attachment));
|
||||
break;
|
||||
|
||||
case MessageType.File:
|
||||
var fileName = message.AttachmentFileName ?? "unknown";
|
||||
var fileSize = FormatFileSize(message.AttachmentFileSize);
|
||||
var fileLine = BuildChatLineColored(time, senderName, senderColor,
|
||||
$" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
|
||||
fileLine.AttachmentUrl = message.AttachmentUrl;
|
||||
fileLine.AttachmentFileName = fileName;
|
||||
fileLine.Type = MessageType.File;
|
||||
lines.Add(fileLine);
|
||||
break;
|
||||
default:
|
||||
lines.Add(AttachmentActionLine(pad,
|
||||
$"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||
ChatColors.FileAttr, attachment));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
case MessageType.Text:
|
||||
default:
|
||||
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||
var contentLines = displayContent.Split('\n');
|
||||
var firstLine = contentLines[0].TrimEnd('\r');
|
||||
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
|
||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
||||
for (int i = 1; i < contentLines.Length; i++)
|
||||
{
|
||||
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
|
||||
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
||||
}
|
||||
|
||||
if (message.Embeds is { Count: > 0 })
|
||||
{
|
||||
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||
foreach (var embed in message.Embeds)
|
||||
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
||||
}
|
||||
break;
|
||||
// Link embeds (from caption URLs)
|
||||
if (message.Embeds is { Count: > 0 })
|
||||
{
|
||||
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||
foreach (var embed in message.Embeds)
|
||||
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
||||
}
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
line.MessageId = message.Id;
|
||||
line.SenderUsername = message.SenderUsername;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
||||
if (hasContent && !string.IsNullOrEmpty(_currentUser))
|
||||
{
|
||||
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
||||
@@ -284,6 +330,23 @@ public sealed class ChatMessageManager
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
||||
/// route activation (play audio, download file, save original image).
|
||||
/// </summary>
|
||||
private static ChatLine AttachmentActionLine(string pad, string text, Attribute color, AttachmentDto attachment)
|
||||
{
|
||||
var line = new ChatLine(new List<ChatSegment>
|
||||
{
|
||||
new(pad, null),
|
||||
new(text, color),
|
||||
});
|
||||
line.AttachmentUrl = attachment.Url;
|
||||
line.AttachmentFileName = attachment.FileName;
|
||||
line.AttachmentKind = attachment.Kind;
|
||||
return line;
|
||||
}
|
||||
|
||||
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
|
||||
{
|
||||
var segments = new List<ChatSegment>
|
||||
@@ -326,18 +389,20 @@ public sealed class ChatMessageManager
|
||||
int textWidth = chatWidth - indentCols - borderCols;
|
||||
if (textWidth < 20) textWidth = 20;
|
||||
|
||||
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
|
||||
|
||||
void AddTextLine(string text, Attribute? color)
|
||||
{
|
||||
lines.Add(new ChatLine(
|
||||
[
|
||||
new ChatSegment(indent, null),
|
||||
new ChatSegment(border, ChatColors.EmbedBorderAttr),
|
||||
new ChatSegment(border, borderAttr),
|
||||
new ChatSegment(text, color)
|
||||
]));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
|
||||
AddTextLine(embed.SiteName, borderAttr);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
{
|
||||
@@ -384,6 +449,14 @@ public sealed class ChatMessageManager
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string FormatDateTime(DateTimeOffset timestamp)
|
||||
{
|
||||
if (timestamp.Date == DateTimeOffset.Now.Date)
|
||||
return timestamp.ToLocalTime().ToString("t");
|
||||
else
|
||||
return timestamp.ToLocalTime().ToString("g");
|
||||
}
|
||||
|
||||
internal static string FormatFileSize(long? bytes)
|
||||
{
|
||||
if (bytes is null or 0)
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class AudioPlayerDialog
|
||||
|
||||
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 ──
|
||||
var fileLabel = new Label
|
||||
@@ -61,38 +61,47 @@ public sealed class AudioPlayerDialog
|
||||
};
|
||||
|
||||
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
|
||||
{
|
||||
Text = "-",
|
||||
X = 10,
|
||||
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
|
||||
{
|
||||
Text = "+",
|
||||
X = 41,
|
||||
X = 35,
|
||||
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 ──
|
||||
@@ -100,22 +109,25 @@ public sealed class AudioPlayerDialog
|
||||
{
|
||||
Text = "\u25b6 Play",
|
||||
X = 2,
|
||||
Y = 10,
|
||||
IsDefault = true
|
||||
Y = 9,
|
||||
IsDefault = true,
|
||||
ShadowStyle = ShadowStyle.None
|
||||
};
|
||||
|
||||
var stopButton = new Button
|
||||
{
|
||||
Text = "\u25a0 Stop",
|
||||
X = Pos.Right(playButton) + 2,
|
||||
Y = 10
|
||||
X = Pos.Right(playButton) + 1,
|
||||
Y = 9,
|
||||
ShadowStyle = ShadowStyle.None
|
||||
};
|
||||
|
||||
var closeButton = new Button
|
||||
{
|
||||
Text = "Close",
|
||||
X = Pos.Right(stopButton) + 2,
|
||||
Y = 10
|
||||
X = Pos.Right(stopButton) + 1,
|
||||
Y = 9,
|
||||
ShadowStyle = ShadowStyle.None
|
||||
};
|
||||
|
||||
// ── Animation state ──
|
||||
@@ -128,6 +140,7 @@ public sealed class AudioPlayerDialog
|
||||
|
||||
Timer? animationTimer = null;
|
||||
var isDisposed = false;
|
||||
var isBusy = false;
|
||||
|
||||
// ── Helper functions ──
|
||||
void UpdateWave(bool isActive)
|
||||
@@ -278,6 +291,8 @@ public sealed class AudioPlayerDialog
|
||||
playButton.Accepting += (s, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
if (isBusy) return;
|
||||
isBusy = true;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
if (audioService.IsPaused)
|
||||
@@ -287,6 +302,7 @@ public sealed class AudioPlayerDialog
|
||||
{
|
||||
UpdateStatus();
|
||||
StartAnimation();
|
||||
isBusy = false;
|
||||
});
|
||||
}
|
||||
else if (audioService.IsPlaying)
|
||||
@@ -296,6 +312,7 @@ public sealed class AudioPlayerDialog
|
||||
{
|
||||
UpdateStatus();
|
||||
StopAnimation();
|
||||
isBusy = false;
|
||||
});
|
||||
}
|
||||
else
|
||||
@@ -306,6 +323,7 @@ public sealed class AudioPlayerDialog
|
||||
{
|
||||
UpdateStatus();
|
||||
StartAnimation();
|
||||
isBusy = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -314,6 +332,8 @@ public sealed class AudioPlayerDialog
|
||||
stopButton.Accepting += (s, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
if (isBusy) return;
|
||||
isBusy = true;
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await audioService.StopAsync();
|
||||
@@ -321,6 +341,7 @@ public sealed class AudioPlayerDialog
|
||||
{
|
||||
UpdateStatus();
|
||||
StopAnimation();
|
||||
isBusy = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -337,6 +358,8 @@ public sealed class AudioPlayerDialog
|
||||
volDownButton.Accepting += (s, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
if (isBusy) return;
|
||||
isBusy = true;
|
||||
var newVol = (byte)Math.Max(0, currentVolume - 10);
|
||||
Task.Run(async () =>
|
||||
{
|
||||
@@ -345,6 +368,7 @@ public sealed class AudioPlayerDialog
|
||||
{
|
||||
volumeBar.SetNeedsDraw();
|
||||
volumePercentLabel.SetNeedsDraw();
|
||||
isBusy = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -352,6 +376,8 @@ public sealed class AudioPlayerDialog
|
||||
volUpButton.Accepting += (s, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
if (isBusy) return;
|
||||
isBusy = true;
|
||||
var newVol = (byte)Math.Min(100, currentVolume + 10);
|
||||
Task.Run(async () =>
|
||||
{
|
||||
@@ -360,6 +386,7 @@ public sealed class AudioPlayerDialog
|
||||
{
|
||||
volumeBar.SetNeedsDraw();
|
||||
volumePercentLabel.SetNeedsDraw();
|
||||
isBusy = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.ViewBase;
|
||||
using Terminal.Gui.Views;
|
||||
|
||||
namespace EchoHub.Client.UI.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Prompts for a channel password when joining a protected channel.
|
||||
/// Returns the entered password, or null if the user cancels.
|
||||
/// </summary>
|
||||
public sealed class ChannelPasswordDialog
|
||||
{
|
||||
public static string? Show(IApplication app, string channelName, string? message = null)
|
||||
{
|
||||
string? result = null;
|
||||
|
||||
var dialog = new Dialog { Title = $"Join #{channelName}", Width = 50, Height = 10, CommandsToBubbleUp = [] };
|
||||
|
||||
var infoLabel = new Label
|
||||
{
|
||||
Text = message ?? $"#{channelName} is password protected.",
|
||||
X = 1,
|
||||
Y = 1
|
||||
};
|
||||
|
||||
var passwordLabel = new Label { Text = "Password:", X = 1, Y = 3 };
|
||||
var passwordField = new TextField { X = 11, Y = 3, Width = Dim.Fill(2), Secret = true };
|
||||
|
||||
var joinButton = new Button
|
||||
{
|
||||
Text = "Join",
|
||||
IsDefault = true,
|
||||
X = Pos.Center() - 9,
|
||||
Y = 5
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center() + 2,
|
||||
Y = 5
|
||||
};
|
||||
|
||||
joinButton.Accepting += (s, e) =>
|
||||
{
|
||||
var password = passwordField.Text;
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
MessageBox.ErrorQuery(app, "Error", "Password is required.", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
result = password;
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
cancelButton.Accepting += (s, e) =>
|
||||
{
|
||||
result = null;
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
dialog.Add(infoLabel, passwordLabel, passwordField, joinButton, cancelButton);
|
||||
|
||||
passwordField.SetFocus();
|
||||
app.Run(dialog);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using Terminal.Gui.ViewBase;
|
||||
|
||||
namespace EchoHub.Client.UI.Dialogs;
|
||||
|
||||
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
|
||||
public record CreateChannelResult(string Name, string? Topic, bool IsPublic, string? Password);
|
||||
|
||||
public sealed class CreateChannelDialog
|
||||
{
|
||||
@@ -12,7 +12,7 @@ public sealed class CreateChannelDialog
|
||||
{
|
||||
CreateChannelResult? result = null;
|
||||
|
||||
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14 };
|
||||
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 16, CommandsToBubbleUp = [] };
|
||||
|
||||
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
|
||||
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
|
||||
@@ -20,19 +20,22 @@ public sealed class CreateChannelDialog
|
||||
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
|
||||
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
|
||||
|
||||
var passwordLabel = new Label { Text = "Password:", X = 1, Y = 5 };
|
||||
var passwordField = new TextField { X = 11, Y = 5, Width = Dim.Fill(2), Secret = true };
|
||||
|
||||
var publicCheckbox = new CheckBox
|
||||
{
|
||||
Text = "Public (visible to all users)",
|
||||
X = 1,
|
||||
Y = 5,
|
||||
Y = 7,
|
||||
Value = CheckState.Checked
|
||||
};
|
||||
|
||||
var hintLabel = new Label
|
||||
{
|
||||
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
|
||||
Text = "Name: a-z, 0-9, -, _ (2-100 chars). Empty password = open channel.",
|
||||
X = 1,
|
||||
Y = 7,
|
||||
Y = 9,
|
||||
};
|
||||
|
||||
var createButton = new Button
|
||||
@@ -40,14 +43,14 @@ public sealed class CreateChannelDialog
|
||||
Text = "Create",
|
||||
IsDefault = true,
|
||||
X = Pos.Center() - 10,
|
||||
Y = 9
|
||||
Y = 11
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center() + 5,
|
||||
Y = 9
|
||||
Y = 11
|
||||
};
|
||||
|
||||
createButton.Accepting += (s, e) =>
|
||||
@@ -63,8 +66,12 @@ public sealed class CreateChannelDialog
|
||||
if (string.IsNullOrWhiteSpace(topic))
|
||||
topic = null;
|
||||
|
||||
var password = passwordField.Text;
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
password = null;
|
||||
|
||||
var isPublic = publicCheckbox.Value == CheckState.Checked;
|
||||
result = new CreateChannelResult(name, topic, isPublic);
|
||||
result = new CreateChannelResult(name, topic, isPublic, password);
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
@@ -76,7 +83,8 @@ public sealed class CreateChannelDialog
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton);
|
||||
dialog.Add(nameLabel, nameField, topicLabel, topicField, passwordLabel, passwordField,
|
||||
publicCheckbox, hintLabel, createButton, cancelButton);
|
||||
|
||||
nameField.SetFocus();
|
||||
app.Run(dialog);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using EchoHub.Client.UI.ListSources;
|
||||
|
||||
using System.Collections;
|
||||
using System.Collections.Specialized;
|
||||
using System.Diagnostics;
|
||||
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Drawing;
|
||||
using Terminal.Gui.Input;
|
||||
using Terminal.Gui.Text;
|
||||
using Terminal.Gui.ViewBase;
|
||||
using Terminal.Gui.Views;
|
||||
|
||||
namespace EchoHub.Client.UI.Dialogs;
|
||||
|
||||
public enum SearchResultType
|
||||
{
|
||||
Channel,
|
||||
Action
|
||||
}
|
||||
|
||||
public record SearchResult(SearchResultType Type, string Key, string Label);
|
||||
|
||||
/// <summary>
|
||||
/// Command-palette style search dialog (Ctrl+K) for navigating channels and triggering app actions.
|
||||
/// </summary>
|
||||
public static class SearchDialog
|
||||
{
|
||||
private static readonly IReadOnlyList<SearchResult> DefaultActions = [
|
||||
new(SearchResultType.Action, "connect", "Connect to Server"),
|
||||
new(SearchResultType.Action, "disconnect", "Disconnect"),
|
||||
new(SearchResultType.Action, "logout", "Logout"),
|
||||
new(SearchResultType.Action, "profile", "My Profile"),
|
||||
new(SearchResultType.Action, "status", "Set Status"),
|
||||
new(SearchResultType.Action, "create-channel", "Create Channel"),
|
||||
new(SearchResultType.Action, "delete-channel", "Delete Channel"),
|
||||
new(SearchResultType.Action, "servers", "Saved Servers"),
|
||||
new(SearchResultType.Action, "toggle-users", "Toggle Users Panel"),
|
||||
new(SearchResultType.Action, "updates", "Check for Updates"),
|
||||
new(SearchResultType.Action, "quit", "Quit"),
|
||||
];
|
||||
|
||||
public static SearchResult? Show(IApplication app, IReadOnlyList<string> channels)
|
||||
{
|
||||
SearchResult? result = null;
|
||||
var source = new SearchListSource(BuildAllItems(channels));
|
||||
|
||||
var dialog = new Dialog
|
||||
{
|
||||
Title = "Search",
|
||||
Width = 59,
|
||||
Height = 22,
|
||||
};
|
||||
|
||||
var hintLabel = new Label
|
||||
{
|
||||
Text = "Channels and actions \u2502 \u2193 to navigate \u2502 Enter to select",
|
||||
X = 1,
|
||||
Y = 1,
|
||||
};
|
||||
|
||||
var searchField = new TextField
|
||||
{
|
||||
X = 1,
|
||||
Y = 2,
|
||||
Title = "Search",
|
||||
Width = Dim.Fill(2),
|
||||
};
|
||||
|
||||
var resultList = new ListView
|
||||
{
|
||||
X = 1,
|
||||
Y = 4,
|
||||
Width = Dim.Fill(2),
|
||||
Height = Dim.Fill(3),
|
||||
Source = source
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center(),
|
||||
Y = Pos.AnchorEnd(1),
|
||||
};
|
||||
|
||||
if (source.Count > 0)
|
||||
resultList.SelectedItem = 0;
|
||||
|
||||
searchField.KeyDown += (s, e) =>
|
||||
{
|
||||
if (e.KeyCode == Key.K.WithCtrl)
|
||||
{
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
}
|
||||
};
|
||||
|
||||
searchField.TextChanged += (s, e) =>
|
||||
{
|
||||
source.Filter(searchField.Text ?? string.Empty);
|
||||
resultList.Source = source;
|
||||
if (source.Count > 0)
|
||||
resultList.SelectedItem = 0;
|
||||
};
|
||||
|
||||
searchField.Accepting += (s, e) => TryConfirm(e);
|
||||
|
||||
resultList.Accepting += (s, e) => TryConfirm(e);
|
||||
|
||||
resultList.KeystrokeNavigator.SearchStringChanged += (s, e) =>
|
||||
{
|
||||
app.Invoke(() =>
|
||||
{
|
||||
searchField.SetFocus();
|
||||
});
|
||||
};
|
||||
|
||||
cancelButton.Accepting += (s, e) =>
|
||||
{
|
||||
result = null;
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
dialog.KeyDown += (s, e) =>
|
||||
{
|
||||
if (e.KeyCode == Key.K.WithCtrl)
|
||||
{
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
}
|
||||
};
|
||||
|
||||
dialog.Add(hintLabel, searchField, resultList, cancelButton);
|
||||
searchField.SetFocus();
|
||||
app.Run(dialog);
|
||||
|
||||
return result;
|
||||
|
||||
void TryConfirm(CommandEventArgs e)
|
||||
{
|
||||
var idx = resultList.SelectedItem ?? 0;
|
||||
if (source.Count > 0 && idx >= 0 && idx < source.Count)
|
||||
{
|
||||
result = source.GetItem(idx);
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<SearchResult> BuildAllItems(IReadOnlyList<string> channels)
|
||||
{
|
||||
var items = new List<SearchResult>();
|
||||
foreach (var ch in channels)
|
||||
items.Add(new SearchResult(SearchResultType.Channel, ch, $"#{ch}"));
|
||||
items.AddRange(DefaultActions);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Text;
|
||||
|
||||
namespace EchoHub.Client.UI.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Recognizes a dragged-and-dropped file (or files) that a terminal delivers into the input as an
|
||||
/// absolute path. Terminals differ: some paste the whole path at once, others send it character by
|
||||
/// character; either way this checks whether the current input text resolves to existing file(s).
|
||||
/// </summary>
|
||||
public static class DroppedFileParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Cheap pre-check so callers only stat the filesystem when the input plausibly holds a path:
|
||||
/// a quoted path, a Windows drive path (<c>X:\</c>/<c>X:/</c>), a UNC path (<c>\\</c>), or a
|
||||
/// POSIX absolute path (<c>/</c>). Normal chat text never starts this way.
|
||||
/// </summary>
|
||||
public static bool LooksLikePath(string text)
|
||||
{
|
||||
var t = text.TrimStart();
|
||||
if (t.Length < 3)
|
||||
return false;
|
||||
if (t[0] is '"' or '/')
|
||||
return true;
|
||||
if (t.StartsWith(@"\\", StringComparison.Ordinal))
|
||||
return true;
|
||||
return char.IsLetter(t[0]) && t[1] == ':' && (t[2] == '\\' || t[2] == '/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when <paramref name="text"/> resolves to one or more existing files.
|
||||
/// Handles a single path (quoted or not, possibly containing spaces) and multiple
|
||||
/// space-separated (optionally quoted) paths. <paramref name="fileExists"/> is injectable
|
||||
/// for testing; production passes <see cref="File.Exists"/>.
|
||||
/// </summary>
|
||||
public static bool TryGetFiles(string text, out List<string> files, Func<string, bool>? fileExists = null)
|
||||
{
|
||||
fileExists ??= File.Exists;
|
||||
files = [];
|
||||
|
||||
var trimmed = text.Trim();
|
||||
if (trimmed.Length < 3 || trimmed.Length > 4096 || trimmed.Contains('\n'))
|
||||
return false;
|
||||
|
||||
// Single path, possibly quoted and/or containing spaces.
|
||||
var unquoted = StripQuotes(trimmed);
|
||||
if (Path.IsPathFullyQualified(unquoted) && fileExists(unquoted))
|
||||
{
|
||||
files.Add(unquoted);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Multiple files: space-separated tokens, each optionally quoted.
|
||||
foreach (var token in TokenizeQuoted(trimmed))
|
||||
{
|
||||
if (!Path.IsPathFullyQualified(token) || !fileExists(token))
|
||||
{
|
||||
files.Clear();
|
||||
return false;
|
||||
}
|
||||
files.Add(token);
|
||||
}
|
||||
|
||||
return files.Count > 0;
|
||||
}
|
||||
|
||||
private static string StripQuotes(string s) =>
|
||||
s.Length >= 2 && ((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\''))
|
||||
? s[1..^1]
|
||||
: s;
|
||||
|
||||
private static IEnumerable<string> TokenizeQuoted(string input)
|
||||
{
|
||||
var current = new StringBuilder();
|
||||
var quote = '\0';
|
||||
|
||||
foreach (var c in input)
|
||||
{
|
||||
if (quote != '\0')
|
||||
{
|
||||
if (c == quote) quote = '\0';
|
||||
else current.Append(c);
|
||||
}
|
||||
else if (c is '"' or '\'')
|
||||
{
|
||||
quote = c;
|
||||
}
|
||||
else if (c == ' ')
|
||||
{
|
||||
if (current.Length > 0)
|
||||
{
|
||||
yield return current.ToString();
|
||||
current.Clear();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
yield return current.ToString();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public class ChannelListSource : IListDataSource
|
||||
{
|
||||
private readonly List<string> _channelNames = [];
|
||||
private readonly Dictionary<string, int> _unreadCounts = [];
|
||||
private readonly HashSet<string> _protectedChannels = [];
|
||||
private string _activeChannel = string.Empty;
|
||||
|
||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||
@@ -28,13 +29,17 @@ public class ChannelListSource : IListDataSource
|
||||
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
||||
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
||||
|
||||
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
|
||||
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
|
||||
IReadOnlySet<string>? protectedChannels = null)
|
||||
{
|
||||
_channelNames.Clear();
|
||||
_channelNames.AddRange(channels);
|
||||
_unreadCounts.Clear();
|
||||
foreach (var kv in unread)
|
||||
_unreadCounts[kv.Key] = kv.Value;
|
||||
_protectedChannels.Clear();
|
||||
if (protectedChannels is not null)
|
||||
_protectedChannels.UnionWith(protectedChannels);
|
||||
_activeChannel = activeChannel;
|
||||
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
||||
if (!SuspendCollectionChangedEvent)
|
||||
@@ -57,7 +62,8 @@ public class ChannelListSource : IListDataSource
|
||||
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
||||
var prefix = isActive ? "> " : " ";
|
||||
var channelText = $"#{name}";
|
||||
// Trailing * marks password-protected (+k) channels
|
||||
var channelText = _protectedChannels.Contains(name) ? $"#{name}*" : $"#{name}";
|
||||
var badge = hasUnread ? $" ({unread})" : "";
|
||||
|
||||
// Resolve Transparent backgrounds to the view's actual background
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using EchoHub.Client.UI.Chat;
|
||||
using EchoHub.Client.UI.Dialogs;
|
||||
|
||||
using System.Collections;
|
||||
using System.Collections.Specialized;
|
||||
|
||||
using Terminal.Gui.Drawing;
|
||||
using Terminal.Gui.Text;
|
||||
using Terminal.Gui.Views;
|
||||
|
||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||
|
||||
namespace EchoHub.Client.UI.ListSources;
|
||||
|
||||
/// <summary>
|
||||
/// List data source for the search dialog with filtering and colored rendering.
|
||||
/// </summary>
|
||||
public class SearchListSource(List<SearchResult> items) : IListDataSource
|
||||
{
|
||||
private readonly List<SearchResult> _allItems = items;
|
||||
private List<SearchResult> _filtered = [.. items];
|
||||
|
||||
private static readonly Attribute ChannelAttribute = new(Color.BrightCyan, Color.None);
|
||||
private static readonly Attribute ActionAttribute = new(Color.White, Color.None);
|
||||
|
||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||
public int Count => _filtered.Count;
|
||||
public int MaxItemLength => _filtered.Count > 0 ? _filtered.Max(i => i.Label.GetColumns()) : 0;
|
||||
public bool SuspendCollectionChangedEvent { get; set; }
|
||||
|
||||
public void Filter(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
_filtered = [.. _allItems];
|
||||
}
|
||||
else
|
||||
{
|
||||
_filtered = [.. _allItems.Where(i =>
|
||||
i.Label.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||
|| i.Key.Contains(query, StringComparison.OrdinalIgnoreCase))];
|
||||
}
|
||||
|
||||
if (!SuspendCollectionChangedEvent)
|
||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||
}
|
||||
|
||||
public SearchResult? GetItem(int index) => index >= 0 && index < _filtered.Count ? _filtered[index] : null;
|
||||
|
||||
public bool IsMarked(int item) => false;
|
||||
public void SetMark(int item, bool value) { }
|
||||
public IList ToList() => _filtered.Select(i => (object)i.Label).ToList();
|
||||
|
||||
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);
|
||||
|
||||
var entry = _filtered[item];
|
||||
var fillAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
||||
|
||||
Attribute itemAttr;
|
||||
if (selected)
|
||||
{
|
||||
itemAttr = fillAttr;
|
||||
}
|
||||
else
|
||||
{
|
||||
var raw = entry.Type switch
|
||||
{
|
||||
SearchResultType.Channel => ChannelAttribute,
|
||||
SearchResultType.Action => ActionAttribute,
|
||||
_ => fillAttr
|
||||
};
|
||||
itemAttr = raw.Background == Color.None ? raw with { Background = fillAttr.Background } : raw;
|
||||
}
|
||||
|
||||
listView.SetAttribute(itemAttr);
|
||||
|
||||
var drawn = RenderHelpers.WriteText(listView, entry.Label, 0, width);
|
||||
|
||||
listView.SetAttribute(fillAttr);
|
||||
for (var i = drawn; i < width; i++)
|
||||
listView.AddStr(" ");
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -12,14 +12,14 @@ namespace EchoHub.Client.UI.ListSources;
|
||||
/// </summary>
|
||||
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 int Count => _users.Count;
|
||||
public int MaxItemLength { get; private 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.AddRange(users);
|
||||
@@ -28,15 +28,18 @@ public class UserListSource : IListDataSource
|
||||
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 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)
|
||||
{
|
||||
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);
|
||||
|
||||
// Find where the name starts (after status icon + space + optional role badge)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using EchoHub.Client.Services;
|
||||
using EchoHub.Client.Themes;
|
||||
using EchoHub.Client.UI.Chat;
|
||||
using EchoHub.Client.UI.Helpers;
|
||||
using EchoHub.Client.UI.ListSources;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using Serilog;
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Configuration;
|
||||
using Terminal.Gui.Drawing;
|
||||
@@ -18,7 +22,7 @@ namespace EchoHub.Client.UI;
|
||||
/// <summary>
|
||||
/// Main Terminal.Gui window for the EchoHub chat client.
|
||||
/// </summary>
|
||||
public sealed class MainWindow : Runnable
|
||||
public sealed partial class MainWindow : Runnable
|
||||
{
|
||||
private readonly IApplication _app;
|
||||
private readonly ListView _channelList;
|
||||
@@ -36,7 +40,9 @@ public sealed class MainWindow : Runnable
|
||||
private readonly UserListSource _usersListSource;
|
||||
private bool _usersPanelVisible = true;
|
||||
private const int UsersPanelWidth = 22;
|
||||
private const string DefaultInputTitle = "Message │ Enter=send │ Tab=complete │ Ctrl+K=search │ F6=pick message";
|
||||
private static readonly Key F2Key = Key.F2;
|
||||
private bool _hasStagedAttachments;
|
||||
|
||||
internal static readonly string AppVersion =
|
||||
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
|
||||
@@ -46,12 +52,18 @@ public sealed class MainWindow : Runnable
|
||||
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
||||
private static readonly Key AltQKey = Key.Q.WithAlt;
|
||||
private static readonly Key TabKey = Key.Tab;
|
||||
private static readonly Key CtrlKKey = Key.K.WithCtrl;
|
||||
private static readonly Key CtrlVKey = Key.V.WithCtrl;
|
||||
private static readonly Key CtrlXKey = Key.X.WithCtrl;
|
||||
private static readonly Key CtrlCKey = Key.C.WithCtrl;
|
||||
private static readonly Key CtrlYKey = Key.Y.WithCtrl;
|
||||
private static readonly Key F6Key = Key.F6;
|
||||
|
||||
// Available slash commands for Tab autocomplete
|
||||
private static readonly string[] SlashCommands =
|
||||
[
|
||||
"/status", "/nick", "/color", "/theme", "/send",
|
||||
"/avatar", "/profile", "/servers", "/join", "/leave",
|
||||
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/size", "/downloadpath",
|
||||
"/topic", "/users", "/kick", "/ban", "/unban",
|
||||
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
|
||||
];
|
||||
@@ -59,6 +71,7 @@ public sealed class MainWindow : Runnable
|
||||
private readonly List<string> _channelNames = [];
|
||||
private readonly Dictionary<string, string?> _channelTopics = [];
|
||||
private readonly Dictionary<string, bool> _channelPublic = [];
|
||||
private readonly HashSet<string> _channelProtected = [];
|
||||
private readonly ChannelListSource _channelListSource;
|
||||
private readonly ChatMessageManager _messageManager;
|
||||
private string _connectionStatus = "Disconnected";
|
||||
@@ -104,11 +117,21 @@ public sealed class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public event Action<string>? OnThemeSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to check for updates.
|
||||
/// </summary>
|
||||
public event Action? OnCheckForUpdatesRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to view saved servers.
|
||||
/// </summary>
|
||||
public event Action? OnSavedServersRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user scrolls to the top of the message list and older messages should be loaded.
|
||||
/// </summary>
|
||||
public event Action? OnLoadMoreRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to create a new channel.
|
||||
/// </summary>
|
||||
@@ -119,6 +142,11 @@ public sealed class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public event Action? OnDeleteChannelRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to rollback to the previous version.
|
||||
/// </summary>
|
||||
public event Action? OnRollbackRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
|
||||
/// </summary>
|
||||
@@ -129,11 +157,37 @@ public sealed class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public event Action<string, string>? OnFileDownloadRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user activates an image's "[save original]" line. Parameters: attachmentUrl, fileName.
|
||||
/// </summary>
|
||||
public event Action<string, string>? OnImageSaveRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
|
||||
/// </summary>
|
||||
public event Action<Guid>? OnDeleteMessageRequested;
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to open the search dialog (via menu or Ctrl+K).
|
||||
/// </summary>
|
||||
public event Action? OnSearchRequested;
|
||||
|
||||
public MainWindow(IApplication app, ChatMessageManager messageManager)
|
||||
{
|
||||
_app = app;
|
||||
_messageManager = messageManager;
|
||||
_messageManager.MessagesChanged += OnMessagesChanged;
|
||||
_messageManager.HistoryPrepended += OnHistoryPrepended;
|
||||
Arrangement = ViewArrangement.Fixed;
|
||||
|
||||
// Menu bar at the top
|
||||
@@ -194,13 +248,18 @@ public sealed class MainWindow : Runnable
|
||||
};
|
||||
_messageList.Source = new ChatListSource();
|
||||
_messageList.Accepting += OnMessageListAccepting;
|
||||
_messageList.KeyDown += OnMessageListKeyDown;
|
||||
_messageList.MouseEvent += OnMessageListMouseEvent;
|
||||
_messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled;
|
||||
_messageList.VerticalScrollBar.Visible = true;
|
||||
|
||||
_chatFrame.Add(_messageList);
|
||||
Add(_chatFrame);
|
||||
|
||||
// Bottom input area
|
||||
_inputFrame = new FrameView
|
||||
{
|
||||
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete",
|
||||
Title = DefaultInputTitle,
|
||||
X = 22,
|
||||
Y = Pos.Bottom(_chatFrame),
|
||||
Width = Dim.Fill(UsersPanelWidth),
|
||||
@@ -215,6 +274,10 @@ public sealed class MainWindow : Runnable
|
||||
Height = Dim.Fill(),
|
||||
WordWrap = true
|
||||
};
|
||||
// Terminal.Gui binds Ctrl+W to Command.Cut, whose OS clipboard write can throw
|
||||
// Win32Exception when another process holds the clipboard, crashing the app.
|
||||
// Rebind it to delete-word-backward (readline behavior), which never touches the clipboard.
|
||||
_inputField.KeyBindings.ReplaceCommands(Key.W.WithCtrl, Command.KillWordLeft);
|
||||
_inputField.KeyDown += OnInputKeyDown;
|
||||
_inputField.ContentsChanged += OnInputContentsChanged;
|
||||
_inputFrame.Add(_inputField);
|
||||
@@ -239,6 +302,7 @@ public sealed class MainWindow : Runnable
|
||||
};
|
||||
_usersListSource = new UserListSource();
|
||||
_usersList.Source = _usersListSource;
|
||||
_usersList.Accepting += OnUsersListAccepting;
|
||||
_usersFrame.Add(_usersList);
|
||||
Add(_usersFrame);
|
||||
|
||||
@@ -267,6 +331,27 @@ public sealed class MainWindow : Runnable
|
||||
KeyDown += OnWindowKeyDown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the attachment staging indicator shown on the input frame's title, including the
|
||||
/// current ASCII-art size for images. Passing an empty list restores the default hint.
|
||||
/// </summary>
|
||||
public void SetStagedAttachments(IReadOnlyList<string> fileNames, string asciiSizeLabel)
|
||||
{
|
||||
_hasStagedAttachments = fileNames.Count > 0;
|
||||
if (fileNames.Count == 0)
|
||||
{
|
||||
_inputFrame.Title = DefaultInputTitle;
|
||||
}
|
||||
else
|
||||
{
|
||||
var names = string.Join(", ", fileNames);
|
||||
if (names.Length > 45)
|
||||
names = names[..42] + "...";
|
||||
_inputFrame.Title = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear";
|
||||
}
|
||||
_inputFrame.SetNeedsDraw();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the currently registered color schemes to all views.
|
||||
/// Call after theme changes to refresh colors.
|
||||
@@ -318,12 +403,20 @@ public sealed class MainWindow : Runnable
|
||||
};
|
||||
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(
|
||||
[
|
||||
new MenuBarItem("_File",
|
||||
[
|
||||
new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty)
|
||||
]),
|
||||
new MenuBarItem("_File", fileItems),
|
||||
new MenuBarItem("_Server", new View[]
|
||||
{
|
||||
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
|
||||
@@ -391,17 +484,206 @@ public sealed class MainWindow : Runnable
|
||||
return;
|
||||
|
||||
var line = source.GetLine(index.Value);
|
||||
if (line?.AttachmentUrl is null || line.AttachmentFileName is null)
|
||||
return;
|
||||
if (line is null) 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.AttachmentKind == AttachmentKind.Audio)
|
||||
{
|
||||
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.AttachmentKind == AttachmentKind.File)
|
||||
{
|
||||
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.AttachmentKind == AttachmentKind.Image)
|
||||
{
|
||||
OnImageSaveRequested?.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;
|
||||
}
|
||||
else if (line.Type == MessageType.File)
|
||||
}
|
||||
|
||||
private void OnMessageListKeyDown(object? sender, Key e)
|
||||
{
|
||||
// F6 returns focus to the input box.
|
||||
if (e.KeyCode == F6Key.KeyCode)
|
||||
{
|
||||
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||
_inputField.SetFocus();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.KeyCode != Key.Delete.KeyCode && e.KeyCode != Key.Backspace.KeyCode)
|
||||
return;
|
||||
|
||||
if (_messageList.Source is not ChatListSource source)
|
||||
return;
|
||||
|
||||
var index = _messageList.SelectedItem;
|
||||
if (!index.HasValue || index.Value < 0 || index.Value >= source.Count)
|
||||
return;
|
||||
|
||||
var line = source.GetLine(index.Value);
|
||||
if (line?.MessageId is not { } messageId)
|
||||
return;
|
||||
|
||||
// Server enforces the real permission (own message, or Mod+ over a lower role);
|
||||
// the client just confirms intent and lets the server reject if disallowed.
|
||||
ConfirmDeleteMessage(messageId);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnMessageListMouseEvent(object? sender, Mouse e)
|
||||
{
|
||||
if (!e.Flags.HasFlag(MouseFlags.RightButtonClicked))
|
||||
return;
|
||||
|
||||
if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos)
|
||||
return;
|
||||
|
||||
var index = _messageList.TopItem + pos.Y;
|
||||
if (index < 0 || index >= source.Count)
|
||||
return;
|
||||
|
||||
// Select the right-clicked row (so the menu acts on it and it highlights), then show the menu.
|
||||
_messageList.SelectedItem = index;
|
||||
_messageList.SetFocus();
|
||||
|
||||
var line = source.GetLine(index);
|
||||
if (line is null)
|
||||
return;
|
||||
|
||||
ShowMessageContextMenu(line, e.ScreenPosition);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and shows a right-click context menu for a message line: attachment actions,
|
||||
/// mention/profile for the sender, copy, and delete (permission enforced server-side).
|
||||
/// </summary>
|
||||
private void ShowMessageContextMenu(ChatLine line, System.Drawing.Point screenPosition)
|
||||
{
|
||||
var items = new List<View>();
|
||||
var sender = line.SenderUsername;
|
||||
|
||||
if (line.AttachmentKind is { } kind && line.AttachmentUrl is { } url && line.AttachmentFileName is { } name)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case AttachmentKind.Image:
|
||||
items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty));
|
||||
break;
|
||||
case AttachmentKind.Audio:
|
||||
items.Add(new MenuItem("Play audio", "", () => OnAudioPlayRequested?.Invoke(url, name), Key.Empty));
|
||||
break;
|
||||
default:
|
||||
items.Add(new MenuItem("Download file", "", () => OnFileDownloadRequested?.Invoke(url, name), Key.Empty));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sender is not null)
|
||||
{
|
||||
items.Add(new MenuItem($"Mention @{sender}", "", () => MentionUser(sender), Key.Empty));
|
||||
items.Add(new MenuItem($"View {sender}'s profile", "", () => OnUserProfileRequested?.Invoke(sender), Key.Empty));
|
||||
}
|
||||
|
||||
items.Add(new MenuItem("Copy text", "", () => CopyToClipboard(line.ToString()), Key.Empty));
|
||||
|
||||
if (line.MessageId is { } messageId)
|
||||
{
|
||||
items.Add(new MenuItem("Copy message ID", "", () => CopyToClipboard(messageId.ToString()), Key.Empty));
|
||||
items.Add(new Line());
|
||||
items.Add(new MenuItem("Delete message", "", () => ConfirmDeleteMessage(messageId), Key.Empty));
|
||||
}
|
||||
|
||||
if (items.Count == 0)
|
||||
return;
|
||||
|
||||
var menu = new PopoverMenu(items);
|
||||
_app.Popovers?.Register(menu);
|
||||
menu.MakeVisible(screenPosition);
|
||||
}
|
||||
|
||||
private void MentionUser(string username)
|
||||
{
|
||||
_inputField.InsertText($"@{username} ");
|
||||
_inputField.SetFocus();
|
||||
}
|
||||
|
||||
private void CopyToClipboard(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
_app.Clipboard?.TrySetClipboardData(text);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Copy to clipboard failed");
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfirmDeleteMessage(Guid messageId)
|
||||
{
|
||||
var confirm = MessageBox.Query(_app, "Delete Message", "Delete this message?", "Delete", "Cancel");
|
||||
if (confirm == 0)
|
||||
OnDeleteMessageRequested?.Invoke(messageId);
|
||||
}
|
||||
|
||||
private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> e)
|
||||
{
|
||||
if (_messageList.VerticalScrollBar.Value == 0)
|
||||
OnLoadMoreRequested?.Invoke();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
OnUserProfileRequested?.Invoke(username);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
@@ -421,7 +703,9 @@ public sealed class MainWindow : Runnable
|
||||
else if (e.KeyCode == EnterKey.KeyCode)
|
||||
{
|
||||
var text = _inputField.Text?.Trim() ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||
// Send when there's text, or when only attachments are staged (empty caption).
|
||||
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
|
||||
&& !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||
{
|
||||
OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
|
||||
_inputField.Text = string.Empty;
|
||||
@@ -433,6 +717,56 @@ public sealed class MainWindow : Runnable
|
||||
_app.RequestStop();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlKKey.KeyCode)
|
||||
{
|
||||
ShowSearchDialog();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == F6Key.KeyCode)
|
||||
{
|
||||
// Move focus into the message list so you can select a message (arrows) and
|
||||
// delete it (Delete). F6 again returns focus here. (Esc is the app quit key.)
|
||||
FocusMessageList();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode)
|
||||
{
|
||||
// If a file was copied in the OS file manager, the clipboard holds a file list
|
||||
// (not text) — attach it. Otherwise paste text. This is the reliable path on
|
||||
// Windows Terminal, which never pastes copied files as text.
|
||||
if (ClipboardFiles.TryGetFiles(out var pastedFiles))
|
||||
StageFiles(pastedFiles);
|
||||
else
|
||||
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlXKey.KeyCode)
|
||||
{
|
||||
GuardedClipboardAction(() => _inputField.Cut(), "cut");
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlCKey.KeyCode)
|
||||
{
|
||||
GuardedClipboardAction(() => _inputField.Copy(), "copy");
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a clipboard-backed edit action, swallowing transient OS clipboard failures
|
||||
/// (e.g. another process holding the Windows clipboard) that would otherwise
|
||||
/// propagate out of the input loop and crash the app.
|
||||
/// </summary>
|
||||
private static void GuardedClipboardAction(Action action, string operation)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Clipboard {Operation} failed", operation);
|
||||
}
|
||||
}
|
||||
|
||||
private bool _suppressEmojiReplace;
|
||||
@@ -446,6 +780,27 @@ public sealed class MainWindow : Runnable
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return;
|
||||
|
||||
// A file dropped onto the terminal is delivered as its absolute path inserted into the
|
||||
// input — often character by character (this Terminal.Gui build has no bracketed-paste
|
||||
// coalescing). As soon as the input resolves to existing file path(s), route them
|
||||
// through /send (which stages them) instead of leaving a raw path to be sent as a message.
|
||||
if (DroppedFileParser.LooksLikePath(text) && DroppedFileParser.TryGetFiles(text, out var droppedFiles)
|
||||
&& !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||
{
|
||||
_suppressEmojiReplace = true;
|
||||
try
|
||||
{
|
||||
_inputField.Text = string.Empty;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressEmojiReplace = false;
|
||||
}
|
||||
|
||||
StageFiles(droppedFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
var replaced = EmojiHelper.ReplaceEmoji(text);
|
||||
if (replaced == text)
|
||||
return;
|
||||
@@ -455,9 +810,15 @@ public sealed class MainWindow : Runnable
|
||||
var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta);
|
||||
|
||||
_suppressEmojiReplace = true;
|
||||
_inputField.Text = replaced;
|
||||
_inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow);
|
||||
_suppressEmojiReplace = false;
|
||||
try
|
||||
{
|
||||
_inputField.Text = replaced;
|
||||
_inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressEmojiReplace = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -492,6 +853,23 @@ public sealed class MainWindow : Runnable
|
||||
if (prefix.Length > text.Length)
|
||||
_inputField.Text = prefix;
|
||||
}
|
||||
|
||||
// Move cursor to end after autocomplete
|
||||
_inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes files (from a drop or a file-clipboard paste) through the /send pipeline, which
|
||||
/// stages them; the next Enter sends them with any typed caption.
|
||||
/// </summary>
|
||||
private void StageFiles(IEnumerable<string> files)
|
||||
{
|
||||
var channel = _messageManager.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel))
|
||||
return;
|
||||
|
||||
foreach (var file in files)
|
||||
OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\"");
|
||||
}
|
||||
|
||||
private void OnChatViewportChanged()
|
||||
@@ -517,6 +895,16 @@ public sealed class MainWindow : Runnable
|
||||
ToggleUsersPanel();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlKKey.KeyCode)
|
||||
{
|
||||
ShowSearchDialog();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowSearchDialog()
|
||||
{
|
||||
OnSearchRequested?.Invoke();
|
||||
}
|
||||
|
||||
private void OnMessagesChanged(string channelName)
|
||||
@@ -527,6 +915,26 @@ public sealed class MainWindow : Runnable
|
||||
RefreshChannelList();
|
||||
}
|
||||
|
||||
private void OnHistoryPrepended(string channelName)
|
||||
{
|
||||
if (channelName != _messageManager.CurrentChannel)
|
||||
return;
|
||||
|
||||
var messages = _messageManager.GetMessages(channelName);
|
||||
if (messages is null)
|
||||
return;
|
||||
|
||||
var oldCount = (_messageList.Source as ChatListSource)?.Count ?? 0;
|
||||
|
||||
RefreshMessages();
|
||||
|
||||
// Scroll to the item that was at the top before the prepend so the user
|
||||
// stays at their previous reading position rather than jumping to the top.
|
||||
var prependedCount = (_messageList.Source as ChatListSource)?.Count - oldCount;
|
||||
if (prependedCount > 0)
|
||||
_messageList.SelectedItem = prependedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the list of available channels, storing topics, and refresh the channel list view.
|
||||
/// </summary>
|
||||
@@ -535,11 +943,14 @@ public sealed class MainWindow : Runnable
|
||||
_channelNames.Clear();
|
||||
_channelTopics.Clear();
|
||||
_channelPublic.Clear();
|
||||
_channelProtected.Clear();
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
_channelNames.Add(ch.Name);
|
||||
_channelTopics[ch.Name] = ch.Topic;
|
||||
_channelPublic[ch.Name] = ch.IsPublic;
|
||||
if (ch.IsProtected)
|
||||
_channelProtected.Add(ch.Name);
|
||||
}
|
||||
RefreshChannelList();
|
||||
}
|
||||
@@ -547,13 +958,23 @@ public sealed class MainWindow : Runnable
|
||||
/// <summary>
|
||||
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
|
||||
/// </summary>
|
||||
public void EnsureChannelInList(string channelName, bool? isPublic = null)
|
||||
public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null)
|
||||
{
|
||||
if (isPublic.HasValue)
|
||||
_channelPublic[channelName] = isPublic.Value;
|
||||
|
||||
if (isProtected.HasValue)
|
||||
{
|
||||
if (isProtected.Value) _channelProtected.Add(channelName);
|
||||
else _channelProtected.Remove(channelName);
|
||||
}
|
||||
|
||||
if (_channelNames.Contains(channelName))
|
||||
{
|
||||
if (isProtected.HasValue)
|
||||
RefreshChannelList();
|
||||
return;
|
||||
}
|
||||
|
||||
_channelNames.Add(channelName);
|
||||
RefreshChannelList();
|
||||
@@ -567,6 +988,7 @@ public sealed class MainWindow : Runnable
|
||||
_channelNames.Remove(channelName);
|
||||
_channelTopics.Remove(channelName);
|
||||
_channelPublic.Remove(channelName);
|
||||
_channelProtected.Remove(channelName);
|
||||
RefreshChannelList();
|
||||
}
|
||||
|
||||
@@ -652,6 +1074,8 @@ public sealed class MainWindow : Runnable
|
||||
{
|
||||
_channelPublic.TryGetValue(currentChannel, out var isPublic);
|
||||
var typeSuffix = isPublic ? "public" : "private";
|
||||
if (_channelProtected.Contains(currentChannel))
|
||||
typeSuffix += " +k";
|
||||
Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr);
|
||||
}
|
||||
|
||||
@@ -715,6 +1139,7 @@ public sealed class MainWindow : Runnable
|
||||
_messageManager.ClearAll();
|
||||
_channelTopics.Clear();
|
||||
_channelPublic.Clear();
|
||||
_channelProtected.Clear();
|
||||
_channelListSource.Update([], [], string.Empty);
|
||||
_channelList.Source = _channelListSource;
|
||||
_chatFrame.Title = "Chat";
|
||||
@@ -734,6 +1159,26 @@ public sealed class MainWindow : Runnable
|
||||
_inputField.SetFocus();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves focus into the message list for selection (arrows) and deletion (Delete). Selects the
|
||||
/// most recent message when nothing is selected. No-op when the channel has no messages.
|
||||
/// </summary>
|
||||
private void FocusMessageList()
|
||||
{
|
||||
if (_messageList.Source is not ChatListSource source || source.Count == 0)
|
||||
return;
|
||||
|
||||
if (!_messageList.SelectedItem.HasValue
|
||||
|| _messageList.SelectedItem < 0
|
||||
|| _messageList.SelectedItem >= source.Count)
|
||||
{
|
||||
_messageList.SelectedItem = source.Count - 1;
|
||||
}
|
||||
|
||||
_messageList.SetFocus();
|
||||
_messageList.SetNeedsDraw();
|
||||
}
|
||||
|
||||
private void RefreshMessages()
|
||||
{
|
||||
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
||||
@@ -753,7 +1198,7 @@ public sealed class MainWindow : Runnable
|
||||
if (width > 0)
|
||||
{
|
||||
foreach (var line in messages)
|
||||
source.AddRange(line.Wrap(width));
|
||||
source.AddRange(line.Wrap(width, line.ContinuationIndent));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -775,7 +1220,7 @@ public sealed class MainWindow : Runnable
|
||||
/// </summary>
|
||||
private void RefreshChannelList()
|
||||
{
|
||||
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel);
|
||||
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelProtected);
|
||||
_channelList.Source = _channelListSource;
|
||||
|
||||
// Restore selection to current channel
|
||||
@@ -848,9 +1293,11 @@ public sealed class MainWindow : Runnable
|
||||
ServerRole.Mod => "\u2740", // ❀
|
||||
_ => ""
|
||||
};
|
||||
var text = $"{statusIcon} {roleTag}{name}";
|
||||
var text = roleTag.Length > 0
|
||||
? $"{statusIcon} {roleTag} {name}"
|
||||
: $"{statusIcon} {name}";
|
||||
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
|
||||
return (text, nameColor);
|
||||
return (text, nameColor, u.Username);
|
||||
}).ToList();
|
||||
|
||||
_usersListSource.Update(displayItems);
|
||||
@@ -858,4 +1305,11 @@ public sealed class MainWindow : Runnable
|
||||
_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();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public static class HubConstants
|
||||
public const int MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
|
||||
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
||||
public const int MaxMessageNewlines = 30;
|
||||
public const int MaxAttachmentsPerMessage = 10;
|
||||
public const int MaxConsecutiveNewlines = 1;
|
||||
public const int AsciiArtWidth = 80;
|
||||
public const int AsciiArtHeight = 40;
|
||||
|
||||
@@ -9,11 +9,12 @@ public static partial class ValidationConstants
|
||||
public const string HexColorPattern = @"^#[0-9a-fA-F]{6}$";
|
||||
|
||||
public const int MaxPasswordLength = 128;
|
||||
public const int MinChannelPasswordLength = 3;
|
||||
public const int MaxDisplayNameLength = 100;
|
||||
public const int MaxBioLength = 500;
|
||||
public const int MaxStatusMessageLength = 100;
|
||||
public const int MaxChannelTopicLength = 500;
|
||||
public const int MaxHistoryCount = 100;
|
||||
public const int MaxHistoryCount = 200;
|
||||
|
||||
[GeneratedRegex(UsernamePattern)]
|
||||
public static partial Regex UsernameRegex();
|
||||
|
||||
@@ -6,17 +6,23 @@ public interface IChannelService
|
||||
{
|
||||
// Channel CRUD
|
||||
Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit);
|
||||
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic);
|
||||
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic,
|
||||
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null);
|
||||
Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic);
|
||||
Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password);
|
||||
Task<ChannelOperationResult> RekeyChannelAsync(Guid callerUserId, string channelName,
|
||||
string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey);
|
||||
Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName);
|
||||
|
||||
// Channel queries
|
||||
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
|
||||
Task<List<ChannelListItem>> GetChannelListAsync();
|
||||
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
|
||||
Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName);
|
||||
Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName);
|
||||
|
||||
// Membership
|
||||
Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName);
|
||||
Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null);
|
||||
}
|
||||
|
||||
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
|
||||
public record ChannelListItem(string Name, string? Topic, int OnlineCount, bool IsPublic = true, bool IsProtected = false);
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace EchoHub.Core.Contracts;
|
||||
public interface IChatBroadcaster
|
||||
{
|
||||
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 SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
|
||||
|
||||
@@ -10,12 +10,12 @@ public interface IChatService
|
||||
Task<string?> UserDisconnectedAsync(string connectionId);
|
||||
|
||||
// Channel operations
|
||||
Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName);
|
||||
Task<(List<MessageDto> History, string? Error, bool PasswordRequired)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName, string? password = null);
|
||||
Task LeaveChannelAsync(string connectionId, string username, string channelName);
|
||||
|
||||
// Messaging
|
||||
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
|
||||
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count);
|
||||
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0);
|
||||
|
||||
// Presence
|
||||
Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage);
|
||||
@@ -25,8 +25,6 @@ public interface IChatService
|
||||
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
||||
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||
|
||||
// Query operations (used by IRC gateway for WHOIS, AUTH)
|
||||
Task<UserProfileDto?> GetUserProfileAsync(string username);
|
||||
// Query operations (used by IRC gateway for WHOIS)
|
||||
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
|
||||
{
|
||||
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 ChannelUpdated(ChannelDto channel);
|
||||
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);
|
||||
}
|
||||
@@ -8,20 +8,31 @@ public record MessageDto(
|
||||
string SenderUsername,
|
||||
string? SenderNicknameColor,
|
||||
string ChannelName,
|
||||
MessageType Type,
|
||||
string? AttachmentUrl,
|
||||
string? AttachmentFileName,
|
||||
DateTimeOffset SentAt,
|
||||
long? AttachmentFileSize = null,
|
||||
List<AttachmentDto>? Attachments = null,
|
||||
List<EmbedDto>? Embeds = null);
|
||||
|
||||
/// <summary>
|
||||
/// A file attached to a message. <see cref="AsciiPreview"/> holds the color-tag art for
|
||||
/// images (null otherwise). For end-to-end encrypted channels the content behind
|
||||
/// <see cref="Url"/> and the preview are ciphertext the server cannot read.
|
||||
/// </summary>
|
||||
public record AttachmentDto(
|
||||
AttachmentKind Kind,
|
||||
string Url,
|
||||
string FileName,
|
||||
long FileSize,
|
||||
string? AsciiPreview = null);
|
||||
|
||||
public record ChannelDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Topic,
|
||||
bool IsPublic,
|
||||
int MessageCount,
|
||||
DateTimeOffset CreatedAt);
|
||||
DateTimeOffset CreatedAt,
|
||||
bool IsProtected = false,
|
||||
bool IsEncrypted = false);
|
||||
|
||||
public record UserDto(
|
||||
Guid Id,
|
||||
@@ -33,15 +44,46 @@ public record UserDto(
|
||||
|
||||
public record SendMessageRequest(string ChannelName, string Content);
|
||||
|
||||
public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true);
|
||||
public record CreateChannelRequest(
|
||||
string Name,
|
||||
string? Topic = null,
|
||||
bool IsPublic = true,
|
||||
string? Password = null,
|
||||
string? EncryptionSalt = null,
|
||||
string? WrappedRoomKey = null);
|
||||
|
||||
/// <summary>
|
||||
/// Public crypto metadata for a channel — enough for a client to derive its join
|
||||
/// credential from a passphrase. Never includes the wrapped room key.
|
||||
/// </summary>
|
||||
public record ChannelCryptoDto(bool IsEncrypted, string? EncryptionSalt);
|
||||
|
||||
/// <summary>
|
||||
/// Passphrase change for an encrypted channel: the client proves knowledge of the old
|
||||
/// passphrase (old auth key), then supplies the re-wrapped room key under the new one.
|
||||
/// </summary>
|
||||
public record RekeyChannelRequest(
|
||||
string OldPassword,
|
||||
string NewPassword,
|
||||
string NewEncryptionSalt,
|
||||
string NewWrappedRoomKey);
|
||||
|
||||
public record UpdateTopicRequest(string? Topic);
|
||||
|
||||
public record SendUrlRequest(string Url);
|
||||
|
||||
public record JoinChannelResult(
|
||||
bool Success,
|
||||
List<MessageDto> History,
|
||||
string? Error = null,
|
||||
bool PasswordRequired = false,
|
||||
string? EncryptionSalt = null,
|
||||
string? WrappedRoomKey = null);
|
||||
|
||||
public record EmbedDto(
|
||||
string? SiteName,
|
||||
string? Title,
|
||||
string? Description,
|
||||
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 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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -6,4 +6,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace EchoHub.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A file attached to a message (image, audio, or any file). A message may carry
|
||||
/// zero or more attachments alongside its text content (Discord-style).
|
||||
/// </summary>
|
||||
public class Attachment
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid MessageId { get; set; }
|
||||
public Message? Message { get; set; }
|
||||
|
||||
public AttachmentKind Kind { get; set; }
|
||||
|
||||
/// <summary>Relative download URL, e.g. <c>/api/files/{fileId}</c>.</summary>
|
||||
public required string Url { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
|
||||
/// <summary>Stored blob size in bytes (ciphertext size for encrypted channels).</summary>
|
||||
public long FileSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rendered ASCII-art preview for images (color-tag format). Null for audio/files.
|
||||
/// Stored encrypted-at-rest when database encryption is enabled, and room-encrypted
|
||||
/// for end-to-end encrypted channels.
|
||||
/// </summary>
|
||||
public string? AsciiPreview { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace EchoHub.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The kind of a message attachment. Determines how the client renders it
|
||||
/// (ASCII preview for images, a play affordance for audio, a download line for files).
|
||||
/// </summary>
|
||||
public enum AttachmentKind
|
||||
{
|
||||
Image,
|
||||
Audio,
|
||||
File
|
||||
}
|
||||
@@ -6,6 +6,13 @@ public class Channel
|
||||
public required string Name { get; set; }
|
||||
public string? Topic { get; set; }
|
||||
public bool IsPublic { get; set; } = true;
|
||||
public string? PasswordHash { get; set; }
|
||||
|
||||
// End-to-end encryption envelope (client-generated; server cannot decrypt room content).
|
||||
// EncryptionSalt: PBKDF2 salt for passphrase-derived keys. WrappedRoomKey: the room
|
||||
// content key encrypted under the passphrase-derived key-encryption key.
|
||||
public string? EncryptionSalt { get; set; }
|
||||
public string? WrappedRoomKey { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public Guid CreatedByUserId { get; set; }
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@ namespace EchoHub.Core.Models;
|
||||
public class Message
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>The message text/caption. May be empty when the message only carries attachments.</summary>
|
||||
public required string Content { get; set; }
|
||||
public MessageType Type { get; set; } = MessageType.Text;
|
||||
public string? AttachmentUrl { get; set; }
|
||||
public string? AttachmentFileName { get; set; }
|
||||
public long? AttachmentFileSize { get; set; }
|
||||
|
||||
public string? EmbedJson { get; set; }
|
||||
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -16,4 +15,16 @@ public class Message
|
||||
|
||||
public Guid SenderUserId { get; set; }
|
||||
public required string SenderUsername { get; set; }
|
||||
|
||||
/// <summary>Files attached to this message. Empty for a plain text message.</summary>
|
||||
public List<Attachment> Attachments { get; set; } = [];
|
||||
|
||||
// ── Legacy columns (pre-attachments model) ──────────────────────────────
|
||||
// Retained so the one-time startup data migration can fold old single-attachment
|
||||
// messages into Attachments. New code never writes these; they are nulled out
|
||||
// once migrated. Not exposed in DTOs. See DataMigrationService.MigrateLegacyAttachmentsAsync.
|
||||
public MessageType Type { get; set; } = MessageType.Text;
|
||||
public string? AttachmentUrl { get; set; }
|
||||
public string? AttachmentFileName { get; set; }
|
||||
public long? AttachmentFileSize { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace EchoHub.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Client-side envelope encryption for private (end-to-end encrypted) channels.
|
||||
///
|
||||
/// Design: at creation the client generates a random 256-bit room content key (RCK)
|
||||
/// that encrypts all room content. The RCK is stored on the server *wrapped*
|
||||
/// (AES-GCM encrypted) by a key derived from the passphrase, next to a BCrypt hash
|
||||
/// of a separately derived auth key used as the join gate. The passphrase, the
|
||||
/// key-encryption key, and the RCK never leave the client, so the server can gate
|
||||
/// joins and count/measure content without being able to read it. Changing the
|
||||
/// passphrase only re-wraps the RCK — history is never re-encrypted.
|
||||
///
|
||||
/// Derivation: PBKDF2-SHA256(passphrase, salt, 210000 iterations) → 64 bytes;
|
||||
/// first 32 bytes are the auth key (sent to the server as lowercase hex),
|
||||
/// last 32 bytes are the key-encryption key (never sent).
|
||||
/// </summary>
|
||||
public static class RoomCrypto
|
||||
{
|
||||
public const string CiphertextPrefix = "$RC1$";
|
||||
|
||||
private const int Pbkdf2Iterations = 210_000;
|
||||
private const int SaltSizeBytes = 16;
|
||||
private const int KeySizeBytes = 32;
|
||||
private const int NonceSizeBytes = 12;
|
||||
private const int TagSizeBytes = 16;
|
||||
|
||||
public sealed record DerivedKeys(string AuthKeyHex, byte[] KeyEncryptionKey);
|
||||
|
||||
public static byte[] GenerateSalt() => RandomNumberGenerator.GetBytes(SaltSizeBytes);
|
||||
|
||||
public static byte[] GenerateRoomKey() => RandomNumberGenerator.GetBytes(KeySizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Derives the auth key (join gate credential) and key-encryption key from a passphrase.
|
||||
/// </summary>
|
||||
public static DerivedKeys DeriveKeys(string passphrase, byte[] salt)
|
||||
{
|
||||
var okm = Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(passphrase), salt, Pbkdf2Iterations,
|
||||
HashAlgorithmName.SHA256, KeySizeBytes * 2);
|
||||
|
||||
var authKey = Convert.ToHexString(okm.AsSpan(0, KeySizeBytes)).ToLowerInvariant();
|
||||
var kek = okm[KeySizeBytes..];
|
||||
CryptographicOperations.ZeroMemory(okm.AsSpan(0, KeySizeBytes));
|
||||
return new DerivedKeys(authKey, kek);
|
||||
}
|
||||
|
||||
/// <summary>Encrypts UTF-8 text with the room key. Output: $RC1$base64(nonce||tag||ciphertext).</summary>
|
||||
public static string EncryptText(string plaintext, byte[] key) =>
|
||||
CiphertextPrefix + Convert.ToBase64String(EncryptBytes(Encoding.UTF8.GetBytes(plaintext), key));
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts text produced by <see cref="EncryptText"/>. Returns false when the input
|
||||
/// is not room ciphertext or the key does not match.
|
||||
/// </summary>
|
||||
public static bool TryDecryptText(string content, byte[] key, out string plaintext)
|
||||
{
|
||||
plaintext = string.Empty;
|
||||
if (!IsRoomCiphertext(content))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var blob = Convert.FromBase64String(content[CiphertextPrefix.Length..]);
|
||||
plaintext = Encoding.UTF8.GetString(DecryptBytes(blob, key));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsRoomCiphertext(string? content) =>
|
||||
content is not null && content.StartsWith(CiphertextPrefix, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>Encrypts a binary blob (file contents) with the room key: nonce||tag||ciphertext.</summary>
|
||||
public static byte[] EncryptBytes(byte[] plaintext, byte[] key)
|
||||
{
|
||||
var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[TagSizeBytes];
|
||||
|
||||
using var aes = new AesGcm(key, TagSizeBytes);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
|
||||
var blob = new byte[NonceSizeBytes + TagSizeBytes + ciphertext.Length];
|
||||
nonce.CopyTo(blob, 0);
|
||||
tag.CopyTo(blob, NonceSizeBytes);
|
||||
ciphertext.CopyTo(blob, NonceSizeBytes + TagSizeBytes);
|
||||
return blob;
|
||||
}
|
||||
|
||||
/// <summary>Decrypts a blob produced by <see cref="EncryptBytes"/>. Throws <see cref="CryptographicException"/> on key mismatch.</summary>
|
||||
public static byte[] DecryptBytes(byte[] blob, byte[] key)
|
||||
{
|
||||
if (blob.Length < NonceSizeBytes + TagSizeBytes)
|
||||
throw new CryptographicException("Ciphertext blob is too short.");
|
||||
|
||||
var nonce = blob.AsSpan(0, NonceSizeBytes);
|
||||
var tag = blob.AsSpan(NonceSizeBytes, TagSizeBytes);
|
||||
var ciphertext = blob.AsSpan(NonceSizeBytes + TagSizeBytes);
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
|
||||
using var aes = new AesGcm(key, TagSizeBytes);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/// <summary>Wraps the room content key under the key-encryption key for server storage.</summary>
|
||||
public static string WrapRoomKey(byte[] roomKey, byte[] kek) =>
|
||||
Convert.ToBase64String(EncryptBytes(roomKey, kek));
|
||||
|
||||
/// <summary>Unwraps the stored room content key. Returns false when the KEK (passphrase) is wrong.</summary>
|
||||
public static bool TryUnwrapRoomKey(string wrappedRoomKey, byte[] kek, out byte[] roomKey)
|
||||
{
|
||||
roomKey = [];
|
||||
try
|
||||
{
|
||||
roomKey = DecryptBytes(Convert.FromBase64String(wrappedRoomKey), kek);
|
||||
return roomKey.Length == KeySizeBytes;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace EchoHub.Server.Services;
|
||||
namespace EchoHub.Core.Services;
|
||||
|
||||
public static class FileValidationHelper
|
||||
{
|
||||
+1
-1
@@ -4,7 +4,7 @@ using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace EchoHub.Server.Services;
|
||||
namespace EchoHub.Core.Services;
|
||||
|
||||
public class ImageToAsciiService
|
||||
{
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -44,8 +44,9 @@ public sealed class IrcClientConnection : IAsyncDisposable
|
||||
public IrcClientConnection(TcpClient tcpClient, Stream stream)
|
||||
{
|
||||
_tcpClient = tcpClient;
|
||||
_reader = new StreamReader(stream, Encoding.UTF8);
|
||||
_writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true, NewLine = "\r\n" };
|
||||
var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||
_reader = new StreamReader(stream, utf8NoBom);
|
||||
_writer = new StreamWriter(stream, utf8NoBom) { AutoFlush = true, NewLine = "\r\n" };
|
||||
}
|
||||
|
||||
public async Task<string?> ReadLineAsync(CancellationToken ct)
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed class IrcCommandHandler
|
||||
private readonly IrcClientConnection _conn;
|
||||
private readonly IrcOptions _options;
|
||||
private readonly IChatService _chatService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IChannelService _channelService;
|
||||
private readonly IMessageEncryptionService _encryption;
|
||||
private readonly ILogger _logger;
|
||||
@@ -22,6 +23,7 @@ public sealed class IrcCommandHandler
|
||||
IrcClientConnection conn,
|
||||
IrcOptions options,
|
||||
IChatService chatService,
|
||||
IUserService userService,
|
||||
IChannelService channelService,
|
||||
IMessageEncryptionService encryption,
|
||||
ILogger logger)
|
||||
@@ -29,6 +31,7 @@ public sealed class IrcCommandHandler
|
||||
_conn = conn;
|
||||
_options = options;
|
||||
_chatService = chatService;
|
||||
_userService = userService;
|
||||
_channelService = channelService;
|
||||
_encryption = encryption;
|
||||
_logger = logger;
|
||||
@@ -44,6 +47,8 @@ public sealed class IrcCommandHandler
|
||||
line = line.TrimEnd('\r', '\n');
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
_logger.LogDebug("IRC < {Id}: {Line}", _conn.ConnectionId, line);
|
||||
|
||||
var msg = IrcMessage.Parse(line);
|
||||
|
||||
try
|
||||
@@ -138,6 +143,15 @@ public sealed class IrcCommandHandler
|
||||
return;
|
||||
}
|
||||
|
||||
// AUTHENTICATE * = client aborts SASL
|
||||
if (msg.Parameters[0] == "*")
|
||||
{
|
||||
_conn.IsSasl = false;
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
|
||||
":SASL authentication aborted");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
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 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,
|
||||
":SASL authentication failed");
|
||||
$":SASL authentication failed — {result.ErrorMessage}");
|
||||
return;
|
||||
}
|
||||
|
||||
_conn.Nickname = result.Value.Username;
|
||||
_conn.UserId = result.Value.UserId;
|
||||
_conn.Nickname = result.User!.Username;
|
||||
_conn.UserId = result.User!.Id;
|
||||
_conn.IsAuthenticated = true;
|
||||
|
||||
_logger.LogInformation("SASL auth succeeded for user '{Username}' (connection {Id})",
|
||||
username, _conn.ConnectionId);
|
||||
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LOGGEDIN,
|
||||
$"{_conn.Hostmask} {username} :You are now logged in as {username}");
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_SASLSUCCESS,
|
||||
":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,
|
||||
":SASL authentication failed");
|
||||
}
|
||||
@@ -240,6 +267,10 @@ public sealed class IrcCommandHandler
|
||||
|
||||
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;
|
||||
|
||||
// SASL already authenticated
|
||||
@@ -260,22 +291,26 @@ public sealed class IrcCommandHandler
|
||||
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,
|
||||
":Password incorrect or account not found. Register via the EchoHub client first.");
|
||||
$":{result.ErrorMessage}");
|
||||
await _conn.SendAsync("ERROR :Authentication failed");
|
||||
return;
|
||||
}
|
||||
|
||||
_conn.UserId = result.Value.UserId;
|
||||
_conn.Nickname = result.Value.Username;
|
||||
_conn.UserId = result.User!.Id;
|
||||
_conn.Nickname = result.User!.Username;
|
||||
_conn.IsAuthenticated = 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();
|
||||
}
|
||||
|
||||
@@ -294,7 +329,7 @@ public sealed class IrcCommandHandler
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO,
|
||||
$"{ServerName} EchoHub-IRC o o");
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT,
|
||||
"CHANTYPES=# NICKLEN=50 CHANNELLEN=100 :are supported by this server");
|
||||
"CHANTYPES=# CHANMODES=b,k,, NICKLEN=50 CHANNELLEN=100 :are supported by this server");
|
||||
|
||||
await SendMotdAsync();
|
||||
}
|
||||
@@ -336,8 +371,17 @@ public sealed class IrcCommandHandler
|
||||
|
||||
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var rawChannel in channels)
|
||||
// RFC 1459: optional second parameter carries comma-separated channel keys,
|
||||
// paired with channels by position (JOIN #a,#b key1,key2).
|
||||
var keys = msg.Parameters.Count > 1
|
||||
? msg.Parameters[1].Split(',')
|
||||
: [];
|
||||
|
||||
for (var i = 0; i < channels.Length; i++)
|
||||
{
|
||||
var rawChannel = channels[i];
|
||||
var key = i < keys.Length && !string.IsNullOrEmpty(keys[i]) ? keys[i] : null;
|
||||
|
||||
var channelName = IrcToEchoHubChannel(rawChannel);
|
||||
if (channelName is null)
|
||||
{
|
||||
@@ -346,13 +390,31 @@ public sealed class IrcCommandHandler
|
||||
continue;
|
||||
}
|
||||
|
||||
var (history, error) = await _chatService.JoinChannelAsync(
|
||||
_conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName);
|
||||
// End-to-end encrypted channels can't be read over IRC (the gateway would
|
||||
// have to hold the room key server-side, defeating the privacy guarantee).
|
||||
var crypto = await _channelService.GetChannelCryptoAsync(channelName);
|
||||
if (crypto?.IsEncrypted == true)
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_BADCHANNELKEY,
|
||||
$"#{channelName} :Cannot join channel — end-to-end encrypted, use the EchoHub client");
|
||||
continue;
|
||||
}
|
||||
|
||||
var (history, error, passwordRequired) = await _chatService.JoinChannelAsync(
|
||||
_conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName, key);
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
|
||||
$"#{channelName} :{error}");
|
||||
if (passwordRequired)
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_BADCHANNELKEY,
|
||||
$"#{channelName} :Cannot join channel (+k) — {error}");
|
||||
}
|
||||
else
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
|
||||
$"#{channelName} :{error}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -477,8 +539,22 @@ public sealed class IrcCommandHandler
|
||||
}
|
||||
else
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CHANOPRIVSNEEDED,
|
||||
$"#{channelName} :Topic can only be changed by the channel creator via the API");
|
||||
var topic = msg.Parameters[1];
|
||||
var result = await _channelService.UpdateTopicAsync(
|
||||
_conn.UserId!.Value, channelName, string.IsNullOrWhiteSpace(topic) ? null : topic);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
var numeric = result.Error == ChannelError.NotFound
|
||||
? IrcNumericReply.ERR_NOSUCHCHANNEL
|
||||
: IrcNumericReply.ERR_CHANOPRIVSNEEDED;
|
||||
await _conn.SendNumericAsync(ServerName, numeric, $"#{channelName} :{result.ErrorMessage}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify SignalR clients and echo the change back to the IRC client
|
||||
await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channelName);
|
||||
await _conn.SendAsync($":{_conn.Hostmask} TOPIC #{channelName} :{result.Channel!.Topic ?? ""}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,7 +603,7 @@ public sealed class IrcCommandHandler
|
||||
if (msg.Parameters.Count < 1) return;
|
||||
|
||||
var nick = msg.Parameters[^1].ToLowerInvariant();
|
||||
var profile = await _chatService.GetUserProfileAsync(nick);
|
||||
var profile = await _userService.GetUserProfileAsync(nick);
|
||||
|
||||
if (profile is null)
|
||||
{
|
||||
@@ -592,10 +668,12 @@ public sealed class IrcCommandHandler
|
||||
|
||||
var channels = await _channelService.GetChannelListAsync();
|
||||
|
||||
foreach (var ch in channels)
|
||||
// Private channels are hidden from discovery, matching the SignalR client's channel list
|
||||
foreach (var ch in channels.Where(c => c.IsPublic))
|
||||
{
|
||||
var lockHint = ch.IsProtected ? "[+k] " : "";
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LIST,
|
||||
$"#{ch.Name} {ch.OnlineCount} :{ch.Topic ?? ""}");
|
||||
$"#{ch.Name} {ch.OnlineCount} :{lockHint}{ch.Topic ?? ""}");
|
||||
}
|
||||
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LISTEND,
|
||||
@@ -609,15 +687,94 @@ public sealed class IrcCommandHandler
|
||||
|
||||
var target = msg.Parameters[0];
|
||||
|
||||
if (target.StartsWith('#'))
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CHANNELMODEIS,
|
||||
$"{target} +");
|
||||
}
|
||||
else
|
||||
if (!target.StartsWith('#'))
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UMODEIS, "+");
|
||||
return;
|
||||
}
|
||||
|
||||
var channelName = IrcToEchoHubChannel(target);
|
||||
if (channelName is null)
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
|
||||
$"{target} :No such channel");
|
||||
return;
|
||||
}
|
||||
|
||||
// Query: MODE #channel
|
||||
if (msg.Parameters.Count == 1)
|
||||
{
|
||||
var channel = await _channelService.GetChannelByNameAsync(channelName);
|
||||
if (channel is null)
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
|
||||
$"#{channelName} :No such channel");
|
||||
return;
|
||||
}
|
||||
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CHANNELMODEIS,
|
||||
$"#{channelName} {(channel.IsProtected ? "+k" : "+")}");
|
||||
return;
|
||||
}
|
||||
|
||||
var modes = msg.Parameters[1];
|
||||
|
||||
// Clients commonly probe the ban list on join — reply with an empty list
|
||||
if (modes is "b" or "+b")
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFBANLIST,
|
||||
$"#{channelName} :End of channel ban list");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (modes)
|
||||
{
|
||||
case "+k":
|
||||
if (msg.Parameters.Count < 3)
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS,
|
||||
"MODE :Not enough parameters");
|
||||
return;
|
||||
}
|
||||
|
||||
var key = msg.Parameters[2];
|
||||
var setResult = await _channelService.SetChannelPasswordAsync(_conn.UserId!.Value, channelName, key);
|
||||
if (!setResult.IsSuccess)
|
||||
{
|
||||
await SendModeErrorAsync(channelName, setResult);
|
||||
return;
|
||||
}
|
||||
|
||||
await _conn.SendAsync($":{_conn.Hostmask} MODE #{channelName} +k {key}");
|
||||
return;
|
||||
|
||||
case "-k":
|
||||
var clearResult = await _channelService.SetChannelPasswordAsync(_conn.UserId!.Value, channelName, null);
|
||||
if (!clearResult.IsSuccess)
|
||||
{
|
||||
await SendModeErrorAsync(channelName, clearResult);
|
||||
return;
|
||||
}
|
||||
|
||||
await _conn.SendAsync($":{_conn.Hostmask} MODE #{channelName} -k *");
|
||||
return;
|
||||
|
||||
default:
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNMODE,
|
||||
$"{modes} :is unknown mode char to me for #{channelName}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendModeErrorAsync(string channelName, ChannelOperationResult result)
|
||||
{
|
||||
var numeric = result.Error switch
|
||||
{
|
||||
ChannelError.NotFound => IrcNumericReply.ERR_NOSUCHCHANNEL,
|
||||
ChannelError.Forbidden => IrcNumericReply.ERR_CHANOPRIVSNEEDED,
|
||||
_ => IrcNumericReply.ERR_KEYSET,
|
||||
};
|
||||
await _conn.SendNumericAsync(ServerName, numeric, $"#{channelName} :{result.ErrorMessage}");
|
||||
}
|
||||
|
||||
private async Task HandlePingAsync(IrcMessage msg)
|
||||
|
||||
@@ -120,10 +120,11 @@ public sealed class IrcGatewayService : BackgroundService
|
||||
try
|
||||
{
|
||||
chatService = _services.GetRequiredService<IChatService>();
|
||||
var userService = _services.GetRequiredService<IUserService>();
|
||||
var channelService = _services.GetRequiredService<IChannelService>();
|
||||
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
|
||||
var handler = new IrcCommandHandler(
|
||||
connection, _options, chatService, channelService, encryption, _logger);
|
||||
connection, _options, chatService, userService, channelService, encryption, _logger);
|
||||
|
||||
await handler.RunAsync(ct);
|
||||
}
|
||||
|
||||
@@ -18,40 +18,49 @@ public static partial class IrcMessageFormatter
|
||||
var ircChannel = $"#{message.ChannelName}";
|
||||
var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub";
|
||||
|
||||
switch (message.Type)
|
||||
// Caption text first (may be empty when the message is attachments-only)
|
||||
if (!string.IsNullOrEmpty(message.Content))
|
||||
{
|
||||
case MessageType.Text:
|
||||
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
||||
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
||||
}
|
||||
|
||||
// Append embed previews if present
|
||||
if (message.Embeds is { Count: > 0 })
|
||||
// One block per attachment
|
||||
if (message.Attachments is { Count: > 0 })
|
||||
{
|
||||
foreach (var attachment in message.Attachments)
|
||||
{
|
||||
switch (attachment.Kind)
|
||||
{
|
||||
foreach (var embed in message.Embeds)
|
||||
lines.AddRange(FormatEmbed(prefix, ircChannel, embed));
|
||||
case AttachmentKind.Image:
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}");
|
||||
if (attachment.AsciiPreview is not null)
|
||||
{
|
||||
foreach (var line in attachment.AsciiPreview.Split('\n'))
|
||||
{
|
||||
var trimmed = line.TrimEnd('\r');
|
||||
if (trimmed.Length > 0)
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case AttachmentKind.Audio:
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {attachment.FileName}] {attachment.Url}");
|
||||
break;
|
||||
|
||||
default:
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {attachment.FileName}] {attachment.Url}");
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
case MessageType.Image:
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {message.AttachmentFileName}]");
|
||||
if (message.AttachmentUrl is not null)
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :Download: {message.AttachmentUrl}");
|
||||
|
||||
foreach (var line in message.Content.Split('\n'))
|
||||
{
|
||||
var trimmed = line.TrimEnd('\r');
|
||||
if (trimmed.Length > 0)
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}");
|
||||
}
|
||||
break;
|
||||
|
||||
case MessageType.File:
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
|
||||
break;
|
||||
|
||||
case MessageType.Audio:
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {message.AttachmentFileName}] {message.AttachmentUrl}");
|
||||
break;
|
||||
// Append embed previews if present
|
||||
if (message.Embeds is { Count: > 0 })
|
||||
{
|
||||
foreach (var embed in message.Embeds)
|
||||
lines.AddRange(FormatEmbed(prefix, ircChannel, embed));
|
||||
}
|
||||
|
||||
return lines;
|
||||
|
||||
@@ -42,6 +42,7 @@ public static class IrcNumericReply
|
||||
// MODE
|
||||
public const string RPL_CHANNELMODEIS = "324";
|
||||
public const string RPL_UMODEIS = "221";
|
||||
public const string RPL_ENDOFBANLIST = "368";
|
||||
|
||||
// Errors
|
||||
public const string ERR_NOSUCHNICK = "401";
|
||||
@@ -56,6 +57,9 @@ public static class IrcNumericReply
|
||||
public const string ERR_NEEDMOREPARAMS = "461";
|
||||
public const string ERR_ALREADYREGISTERED = "462";
|
||||
public const string ERR_PASSWDMISMATCH = "464";
|
||||
public const string ERR_KEYSET = "467";
|
||||
public const string ERR_UNKNOWNMODE = "472";
|
||||
public const string ERR_BADCHANNELKEY = "475";
|
||||
public const string ERR_CHANOPRIVSNEEDED = "482";
|
||||
|
||||
// SASL
|
||||
|
||||
@@ -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.Cryptography;
|
||||
using System.Text;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
@@ -51,6 +52,31 @@ public class JwtTokenService
|
||||
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()
|
||||
{
|
||||
var randomBytes = new byte[64];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.Contracts;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Auth;
|
||||
@@ -16,94 +16,59 @@ public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly EchoHubDbContext _db;
|
||||
private readonly JwtTokenService _jwt;
|
||||
private readonly IUserService _userService;
|
||||
|
||||
public AuthController(EchoHubDbContext db, JwtTokenService jwt)
|
||||
public AuthController(EchoHubDbContext db, JwtTokenService jwt, IUserService userService)
|
||||
{
|
||||
_db = db;
|
||||
_jwt = jwt;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return BadRequest(new ErrorResponse("Username and password are required."));
|
||||
var result = await _userService.RegisterUserAsync(request.Username, request.Password, request.DisplayName);
|
||||
if (!result.IsSuccess)
|
||||
return MapUserError(result);
|
||||
|
||||
if (!ValidationConstants.UsernameRegex().IsMatch(request.Username))
|
||||
return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens."));
|
||||
|
||||
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 profile = result.User!;
|
||||
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(profile);
|
||||
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||
|
||||
_db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||
UserId = user.Id,
|
||||
UserId = profile.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||
});
|
||||
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")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return BadRequest(new ErrorResponse("Username and password are required."));
|
||||
var result = await _userService.AuthenticateUserAsync(request.Username, request.Password);
|
||||
if (!result.IsSuccess)
|
||||
return MapUserError(result);
|
||||
|
||||
var normalizedUsername = request.Username.ToLowerInvariant().Trim();
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
|
||||
|
||||
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 profile = result.User!;
|
||||
var (accessToken, expiresAt) = _jwt.GenerateAccessToken(profile);
|
||||
var refreshToken = JwtTokenService.GenerateRefreshToken();
|
||||
|
||||
_db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TokenHash = JwtTokenService.HashToken(refreshToken),
|
||||
UserId = user.Id,
|
||||
UserId = profile.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
|
||||
});
|
||||
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")]
|
||||
@@ -159,4 +124,14 @@ public class AuthController : ControllerBase
|
||||
|
||||
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,6 +1,8 @@
|
||||
using System.Security.Claims;
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.Contracts;
|
||||
using EchoHub.Core.Security;
|
||||
using EchoHub.Core.Services;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Data;
|
||||
@@ -65,7 +67,8 @@ public class ChannelsController : ControllerBase
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var result = await _channelService.CreateChannelAsync(
|
||||
Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic);
|
||||
Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic, request.Password,
|
||||
request.EncryptionSalt, request.WrappedRoomKey);
|
||||
if (!result.IsSuccess)
|
||||
return MapChannelError(result);
|
||||
|
||||
@@ -75,6 +78,43 @@ public class ChannelsController : ControllerBase
|
||||
return Created($"/api/channels/{result.Channel.Name}", result.Channel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public crypto metadata for a channel: whether it is end-to-end encrypted and the
|
||||
/// PBKDF2 salt clients need to derive their join credential. Never returns the
|
||||
/// wrapped room key — that is only handed out after a successful join.
|
||||
/// </summary>
|
||||
[HttpGet("{channel}/crypto")]
|
||||
public async Task<IActionResult> GetChannelCrypto(string channel)
|
||||
{
|
||||
var crypto = await _channelService.GetChannelCryptoAsync(channel);
|
||||
if (crypto is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channel}' does not exist."));
|
||||
|
||||
return Ok(crypto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes an encrypted channel's passphrase by re-wrapping its room key.
|
||||
/// The caller proves knowledge of the old passphrase via the old auth key;
|
||||
/// history is never re-encrypted (the room content key does not change).
|
||||
/// </summary>
|
||||
[HttpPost("{channel}/rekey")]
|
||||
public async Task<IActionResult> RekeyChannel(string channel, [FromBody] RekeyChannelRequest request)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var result = await _channelService.RekeyChannelAsync(
|
||||
Guid.Parse(userIdClaim), channel,
|
||||
request.OldPassword, request.NewPassword,
|
||||
request.NewEncryptionSalt, request.NewWrappedRoomKey);
|
||||
if (!result.IsSuccess)
|
||||
return MapChannelError(result);
|
||||
|
||||
return Ok(result.Channel);
|
||||
}
|
||||
|
||||
[HttpPut("{channel}/topic")]
|
||||
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
|
||||
{
|
||||
@@ -105,11 +145,18 @@ public class ChannelsController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{channel}/upload")]
|
||||
/// <summary>
|
||||
/// Sends one message carrying optional text (<c>content</c> form field) plus zero or more
|
||||
/// file attachments (Discord-style). For non-encrypted channels the server sniffs each file's
|
||||
/// kind and renders ASCII previews for images. For end-to-end encrypted channels the client
|
||||
/// uploads ciphertext blobs and declares each file's kind (<c>kind</c>) and pre-rendered,
|
||||
/// room-encrypted preview (<c>preview</c>), aligned by file order — the server never inspects them.
|
||||
/// </summary>
|
||||
[HttpPost("{channel}/messages")]
|
||||
[EnableRateLimiting("upload")]
|
||||
[RequestSizeLimit(HubConstants.MaxFileSizeBytes)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)]
|
||||
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
|
||||
[RequestSizeLimit((long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = (long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
|
||||
public async Task<IActionResult> SendMessageWithAttachments(string channel, [FromQuery] string? size = null)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var usernameClaim = User.FindFirstValue("username");
|
||||
@@ -126,80 +173,136 @@ public class ChannelsController : ControllerBase
|
||||
if (channelDto is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
||||
return BadRequest(new ErrorResponse("No file uploaded."));
|
||||
if (!Request.HasFormContentType)
|
||||
return BadRequest(new ErrorResponse("Expected multipart form data."));
|
||||
|
||||
var file = Request.Form.Files[0];
|
||||
var files = Request.Form.Files;
|
||||
if (files.Count == 0)
|
||||
return BadRequest(new ErrorResponse("At least one attachment is required. Send plain text over the chat connection."));
|
||||
if (files.Count > HubConstants.MaxAttachmentsPerMessage)
|
||||
return BadRequest(new ErrorResponse($"A message may carry at most {HubConstants.MaxAttachmentsPerMessage} attachments."));
|
||||
|
||||
// Detect file type early so we can apply the correct size limit
|
||||
using var stream = file.OpenReadStream();
|
||||
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
||||
|
||||
var maxSize = isImage ? HubConstants.MaxImageSizeBytes
|
||||
: isAudio ? HubConstants.MaxAudioFileSizeBytes
|
||||
: HubConstants.MaxFileSizeBytes;
|
||||
|
||||
if (file.Length > maxSize)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB."));
|
||||
|
||||
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
||||
|
||||
var messageType = isImage ? MessageType.Image
|
||||
: isAudio ? MessageType.Audio
|
||||
: MessageType.File;
|
||||
string content;
|
||||
|
||||
if (isImage)
|
||||
{
|
||||
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||
using var imageStream = System.IO.File.OpenRead(filePath);
|
||||
content = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||
}
|
||||
else
|
||||
{
|
||||
content = file.FileName;
|
||||
}
|
||||
|
||||
var attachmentUrl = $"/api/files/{fileId}";
|
||||
var sender = await _db.Users.FindAsync(userId);
|
||||
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
||||
if (sender is not null && sender.IsMuted && (sender.MutedUntil is null || sender.MutedUntil > DateTimeOffset.UtcNow))
|
||||
return StatusCode(403, new ErrorResponse("You are muted and cannot send messages."));
|
||||
|
||||
// Caption: plaintext for normal channels, $RC1$ room-ciphertext for encrypted ones.
|
||||
// Decrypt() is a pass-through when there is no transport prefix.
|
||||
var content = _encryption.Decrypt(Request.Form["content"].ToString());
|
||||
var isRoomCiphertext = RoomCrypto.IsRoomCiphertext(content);
|
||||
if (!isRoomCiphertext && content.Length > HubConstants.MaxMessageLength)
|
||||
return BadRequest(new ErrorResponse($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."));
|
||||
|
||||
var declaredKinds = Request.Form["kind"];
|
||||
var declaredPreviews = Request.Form["preview"];
|
||||
|
||||
var attachmentEntities = new List<Attachment>();
|
||||
var attachmentDtos = new List<AttachmentDto>();
|
||||
|
||||
for (var i = 0; i < files.Count; i++)
|
||||
{
|
||||
var file = files[i];
|
||||
AttachmentKind kind;
|
||||
string? previewPlain;
|
||||
string fileId;
|
||||
|
||||
if (channelDto.IsEncrypted)
|
||||
{
|
||||
// Ciphertext blob — trust the client's declared kind + room-encrypted preview.
|
||||
// Client sends one kind + preview per file in order; empty preview means none.
|
||||
kind = ParseKind(i < declaredKinds.Count ? declaredKinds[i] : null);
|
||||
previewPlain = i < declaredPreviews.Count ? declaredPreviews[i] : null;
|
||||
if (string.IsNullOrEmpty(previewPlain))
|
||||
previewPlain = null;
|
||||
|
||||
if (file.Length > MaxForKind(kind))
|
||||
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size."));
|
||||
|
||||
using var encryptedStream = file.OpenReadStream();
|
||||
(fileId, _) = await _fileStorage.SaveFileAsync(encryptedStream, file.FileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
||||
kind = isImage ? AttachmentKind.Image : isAudio ? AttachmentKind.Audio : AttachmentKind.File;
|
||||
|
||||
if (file.Length > MaxForKind(kind))
|
||||
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {MaxForKind(kind) / (1024 * 1024)} MB."));
|
||||
|
||||
string filePath;
|
||||
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
||||
|
||||
if (isImage)
|
||||
{
|
||||
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||
using var imageStream = System.IO.File.OpenRead(filePath);
|
||||
previewPlain = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||
}
|
||||
else
|
||||
{
|
||||
previewPlain = null;
|
||||
}
|
||||
}
|
||||
|
||||
var url = $"/api/files/{fileId}";
|
||||
attachmentEntities.Add(new Attachment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Kind = kind,
|
||||
Url = url,
|
||||
FileName = file.FileName,
|
||||
FileSize = file.Length,
|
||||
AsciiPreview = _encryption.EncryptDatabaseEnabled ? _encryption.EncryptNullable(previewPlain) : previewPlain,
|
||||
});
|
||||
attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length,
|
||||
_encryption.EncryptNullable(previewPlain)));
|
||||
}
|
||||
|
||||
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
||||
var message = new Message
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Content = dbContent,
|
||||
Type = messageType,
|
||||
AttachmentUrl = attachmentUrl,
|
||||
AttachmentFileName = file.FileName,
|
||||
AttachmentFileSize = file.Length,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
ChannelId = channelDto.Id,
|
||||
SenderUserId = userId,
|
||||
SenderUsername = usernameClaim,
|
||||
Attachments = attachmentEntities,
|
||||
};
|
||||
|
||||
_db.Messages.Add(message);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Encrypt for transport — clients decrypt
|
||||
var messageDto = new MessageDto(
|
||||
message.Id,
|
||||
_encryption.Encrypt(content),
|
||||
message.SenderUsername,
|
||||
sender?.NicknameColor,
|
||||
channelName,
|
||||
messageType,
|
||||
attachmentUrl,
|
||||
file.FileName,
|
||||
message.SentAt,
|
||||
file.Length);
|
||||
attachmentDtos);
|
||||
|
||||
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||
|
||||
return Ok(messageDto);
|
||||
}
|
||||
|
||||
private static AttachmentKind ParseKind(string? kind) => kind?.ToLowerInvariant() switch
|
||||
{
|
||||
"image" => AttachmentKind.Image,
|
||||
"audio" => AttachmentKind.Audio,
|
||||
_ => AttachmentKind.File,
|
||||
};
|
||||
|
||||
private static long MaxForKind(AttachmentKind kind) => kind switch
|
||||
{
|
||||
AttachmentKind.Image => HubConstants.MaxImageSizeBytes,
|
||||
AttachmentKind.Audio => HubConstants.MaxAudioFileSizeBytes,
|
||||
_ => HubConstants.MaxFileSizeBytes,
|
||||
};
|
||||
|
||||
[HttpPost("{channel}/send-url")]
|
||||
[EnableRateLimiting("upload")]
|
||||
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
|
||||
@@ -219,6 +322,10 @@ public class ChannelsController : ControllerBase
|
||||
if (channelDto is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (channelDto.IsEncrypted)
|
||||
return BadRequest(new ErrorResponse(
|
||||
"Sending images by URL is not available in end-to-end encrypted channels — download the image and /send the file instead."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Url))
|
||||
return BadRequest(new ErrorResponse("URL is required."));
|
||||
|
||||
@@ -276,46 +383,49 @@ public class ChannelsController : ControllerBase
|
||||
// Save file and convert to ASCII
|
||||
var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName);
|
||||
|
||||
string content;
|
||||
string preview;
|
||||
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||
using (var imageStream = System.IO.File.OpenRead(filePath))
|
||||
{
|
||||
content = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||
preview = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||
}
|
||||
|
||||
var attachmentUrl = $"/api/files/{fileId}";
|
||||
var sender = await _db.Users.FindAsync(userId);
|
||||
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
||||
|
||||
// A URL-shared image is a message with no caption and one image attachment.
|
||||
var attachment = new Attachment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Kind = AttachmentKind.Image,
|
||||
Url = attachmentUrl,
|
||||
FileName = fileName,
|
||||
FileSize = imageBytes.Length,
|
||||
AsciiPreview = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(preview) : preview,
|
||||
};
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Content = dbContent,
|
||||
Type = MessageType.Image,
|
||||
AttachmentUrl = attachmentUrl,
|
||||
AttachmentFileName = fileName,
|
||||
AttachmentFileSize = imageBytes.Length,
|
||||
Content = string.Empty,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
ChannelId = channelDto.Id,
|
||||
SenderUserId = userId,
|
||||
SenderUsername = usernameClaim,
|
||||
Attachments = [attachment],
|
||||
};
|
||||
|
||||
_db.Messages.Add(message);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Encrypt for transport — clients decrypt
|
||||
var messageDto = new MessageDto(
|
||||
message.Id,
|
||||
_encryption.Encrypt(content),
|
||||
_encryption.Encrypt(string.Empty),
|
||||
message.SenderUsername,
|
||||
sender?.NicknameColor,
|
||||
channelName,
|
||||
MessageType.Image,
|
||||
attachmentUrl,
|
||||
fileName,
|
||||
message.SentAt,
|
||||
imageBytes.Length);
|
||||
[new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))]);
|
||||
|
||||
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||
|
||||
|
||||
@@ -20,17 +20,20 @@ public class ModerationController : ControllerBase
|
||||
private readonly EchoHubDbContext _db;
|
||||
private readonly IChatService _chatService;
|
||||
private readonly PresenceTracker _presenceTracker;
|
||||
private readonly FileStorageService _fileStorage;
|
||||
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||
|
||||
public ModerationController(
|
||||
EchoHubDbContext db,
|
||||
IChatService chatService,
|
||||
PresenceTracker presenceTracker,
|
||||
FileStorageService fileStorage,
|
||||
IEnumerable<IChatBroadcaster> broadcasters)
|
||||
{
|
||||
_db = db;
|
||||
_chatService = chatService;
|
||||
_presenceTracker = presenceTracker;
|
||||
_fileStorage = fileStorage;
|
||||
_broadcasters = broadcasters;
|
||||
}
|
||||
|
||||
@@ -170,17 +173,47 @@ public class ModerationController : ControllerBase
|
||||
[HttpDelete("messages/{messageId:guid}")]
|
||||
public async Task<IActionResult> DeleteMessage(Guid messageId)
|
||||
{
|
||||
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
||||
if (error is not null) return error;
|
||||
// Any authenticated user may reach this; permission depends on authorship + role hierarchy.
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
|
||||
if (caller is null)
|
||||
return Unauthorized(new ErrorResponse("User not found."));
|
||||
|
||||
var message = await _db.Messages
|
||||
.Include(m => m.Channel)
|
||||
.Include(m => m.Attachments)
|
||||
.FirstOrDefaultAsync(m => m.Id == messageId);
|
||||
|
||||
if (message is null)
|
||||
return NotFound(new ErrorResponse("Message not found."));
|
||||
|
||||
var isOwnMessage = message.SenderUserId == caller.Id;
|
||||
if (!isOwnMessage)
|
||||
{
|
||||
// Deleting someone else's message requires Mod+ AND a strictly higher role than
|
||||
// the message author (so a mod can't delete an admin's/owner's message).
|
||||
if (caller.Role < ServerRole.Mod)
|
||||
return StatusCode(403, new ErrorResponse("You can only delete your own messages."));
|
||||
|
||||
var author = await _db.Users.FindAsync(message.SenderUserId);
|
||||
var authorRole = author?.Role ?? ServerRole.Member;
|
||||
if (authorRole >= caller.Role)
|
||||
return StatusCode(403, new ErrorResponse("You cannot delete a message from a user with an equal or higher role."));
|
||||
}
|
||||
|
||||
var channelName = message.Channel!.Name;
|
||||
|
||||
// Remove attachment blobs from disk before the DB rows cascade away.
|
||||
foreach (var attachment in message.Attachments)
|
||||
{
|
||||
var fileId = attachment.Url.Split('/').LastOrDefault();
|
||||
if (!string.IsNullOrEmpty(fileId))
|
||||
_fileStorage.DeleteFile(fileId);
|
||||
}
|
||||
|
||||
_db.Messages.Remove(message);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
@@ -200,7 +233,19 @@ public class ModerationController : ControllerBase
|
||||
if (dbChannel is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
var messages = await _db.Messages.Where(m => m.ChannelId == dbChannel.Id).ToListAsync();
|
||||
var messages = await _db.Messages
|
||||
.Where(m => m.ChannelId == dbChannel.Id)
|
||||
.Include(m => m.Attachments)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var fileId in messages
|
||||
.SelectMany(m => m.Attachments)
|
||||
.Select(a => a.Url.Split('/').LastOrDefault())
|
||||
.Where(id => !string.IsNullOrEmpty(id)))
|
||||
{
|
||||
_fileStorage.DeleteFile(fileId!);
|
||||
}
|
||||
|
||||
_db.Messages.RemoveRange(messages);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user