Refactor and update various components of EchoHub.Server

- Updated launchSettings.json for consistency.
- Refactored FileValidationHelper to improve image validation logic.
- Enhanced ServerDirectoryService for better connection handling and user count updates.
- Improved DatabaseSetup for legacy database handling and seeding default channels.
- Refined FirstRunSetup to ensure JWT secret generation.
- Removed appsettings.Development.json as it is no longer needed.
- Updated EchoHub.Tests project file for consistency.
- Added unit tests for FileValidationHelper and PresenceTracker with improved assertions.
- Updated ValidationConstantsTests to ensure regex validations are correct.
- Cleaned up solution file formatting for better readability.
This commit is contained in:
HueByte
2026-02-19 10:12:28 +01:00
parent 32e01e8d71
commit 9975f4e4be
57 changed files with 4401 additions and 4378 deletions
+12 -12
View File
@@ -1,12 +1,12 @@
{ {
"version": 1, "version": 1,
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"docfx": { "docfx": {
"version": "2.78.3", "version": "2.78.3",
"commands": [ "commands": [
"docfx" "docfx"
] ]
} }
} }
} }
+24
View File
@@ -0,0 +1,24 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
indent_style = space
indent_size = 4
[*.{cs,csx}]
indent_size = 4
[*.{json,yml,yaml}]
indent_size = 2
[*.{xml,csproj,props,targets,slnx}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
+13 -2
View File
@@ -1,2 +1,13 @@
# Force LF line endings for shell scripts (required for Linux CI runners) # Normalize all text files to LF in the repository
*.sh text eol=lf * text=auto eol=lf
# Explicitly mark binary files
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.zip binary
*.db binary
+79 -79
View File
@@ -1,79 +1,79 @@
name: Bug report name: Bug report
description: Report a reproducible bug description: Report a reproducible bug
title: "bug: " title: "bug: "
labels: [bug] labels: [bug]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Thanks for taking the time to report a bug. Thanks for taking the time to report a bug.
If this is a security issue (auth bypass, token leak, file upload exploit, etc.), please follow SECURITY.md instead of filing a public issue. If this is a security issue (auth bypass, token leak, file upload exploit, etc.), please follow SECURITY.md instead of filing a public issue.
- type: dropdown - type: dropdown
id: component id: component
attributes: attributes:
label: Affected component label: Affected component
options: options:
- Client (EchoHub.Client) - Client (EchoHub.Client)
- Server (EchoHub.Server) - Server (EchoHub.Server)
- Core/shared (EchoHub.Core) - Core/shared (EchoHub.Core)
- Docs - Docs
- CI - CI
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
label: What happened? label: What happened?
description: Describe the bug and what you expected to happen. description: Describe the bug and what you expected to happen.
placeholder: "When I..., I expected..., but ..." placeholder: "When I..., I expected..., but ..."
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: repro id: repro
attributes: attributes:
label: Steps to reproduce label: Steps to reproduce
description: Include minimal, numbered steps. description: Include minimal, numbered steps.
placeholder: | placeholder: |
1. ... 1. ...
2. ... 2. ...
3. ... 3. ...
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: logs id: logs
attributes: attributes:
label: Logs / stack traces label: Logs / stack traces
description: Paste relevant logs (redact tokens/secrets). description: Paste relevant logs (redact tokens/secrets).
render: text render: text
- type: input - type: input
id: version id: version
attributes: attributes:
label: Version label: Version
description: App version, commit SHA, or release tag. description: App version, commit SHA, or release tag.
placeholder: v0.1.0 placeholder: v0.1.0
- type: textarea - type: textarea
id: environment id: environment
attributes: attributes:
label: Environment label: Environment
description: OS, terminal, .NET SDK version. description: OS, terminal, .NET SDK version.
placeholder: | placeholder: |
OS: Windows 11 OS: Windows 11
Terminal: Windows Terminal Terminal: Windows Terminal
.NET SDK: 10.0.x .NET SDK: 10.0.x
- type: checkboxes - type: checkboxes
id: confirmations id: confirmations
attributes: attributes:
label: Confirmations label: Confirmations
options: options:
- label: I searched existing issues and this is not a duplicate - label: I searched existing issues and this is not a duplicate
required: true required: true
- label: I removed sensitive info (tokens/keys) from logs - label: I removed sensitive info (tokens/keys) from logs
required: true required: true
+5 -5
View File
@@ -1,5 +1,5 @@
blank_issues_enabled: true blank_issues_enabled: true
contact_links: contact_links:
- name: Security policy - name: Security policy
url: https://github.com/HueByte/EchoHub/security/policy url: https://github.com/HueByte/EchoHub/security/policy
about: Please report security vulnerabilities here (or see SECURITY.md). about: Please report security vulnerabilities here (or see SECURITY.md).
+51 -51
View File
@@ -1,51 +1,51 @@
name: Feature request name: Feature request
description: Suggest an idea or improvement description: Suggest an idea or improvement
title: "feat: " title: "feat: "
labels: [enhancement] labels: [enhancement]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Thanks for suggesting an improvement. Thanks for suggesting an improvement.
EchoHub tries to stay lightweight: terminal-first, self-hosted, and no upsells. EchoHub tries to stay lightweight: terminal-first, self-hosted, and no upsells.
- type: dropdown - type: dropdown
id: area id: area
attributes: attributes:
label: Area label: Area
options: options:
- Client (TUI) - Client (TUI)
- Server (API/SignalR) - Server (API/SignalR)
- Auth/security - Auth/security
- Files/uploads - Files/uploads
- Docs - Docs
- Build/CI - Build/CI
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: problem id: problem
attributes: attributes:
label: What problem are you trying to solve? label: What problem are you trying to solve?
placeholder: "Its hard to..., because ..." placeholder: "Its hard to..., because ..."
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: proposal id: proposal
attributes: attributes:
label: Proposed solution label: Proposed solution
placeholder: "Add/Change ..., so that ..." placeholder: "Add/Change ..., so that ..."
- type: textarea - type: textarea
id: alternatives id: alternatives
attributes: attributes:
label: Alternatives considered label: Alternatives considered
description: Any other approaches youve thought about. description: Any other approaches youve thought about.
- type: textarea - type: textarea
id: extra id: extra
attributes: attributes:
label: Additional context label: Additional context
description: Mockups, examples, links, etc. description: Mockups, examples, links, etc.
+28 -28
View File
@@ -1,28 +1,28 @@
# Summary # Summary
<!-- What does this PR change, and why? Keep it brief. --> <!-- What does this PR change, and why? Keep it brief. -->
## Changes ## Changes
- Describe the changes here - Describe the changes here
## Screenshots / recordings (optional) ## Screenshots / recordings (optional)
<!-- If you changed the TUI UI/UX, add a screenshot or short recording. --> <!-- If you changed the TUI UI/UX, add a screenshot or short recording. -->
## How to test ## How to test
- [ ] `dotnet build src/EchoHub.slnx` - [ ] `dotnet build src/EchoHub.slnx`
- [ ] `dotnet test src/EchoHub.slnx` - [ ] `dotnet test src/EchoHub.slnx`
## Checklist ## Checklist
- [ ] I ran tests locally (or explained why not) - [ ] I ran tests locally (or explained why not)
- [ ] I kept changes focused and easy to review - [ ] I kept changes focused and easy to review
- [ ] I updated docs where needed (README/docs) - [ ] I updated docs where needed (README/docs)
- [ ] I verified no secrets/keys are committed - [ ] I verified no secrets/keys are committed
- [ ] If this touches files/uploads/auth, I considered security implications - [ ] If this touches files/uploads/auth, I considered security implications
## Related issues ## Related issues
- Closes # - Closes #
+165 -165
View File
@@ -1,165 +1,165 @@
name: CI / Release name: CI / Release
on: on:
push: push:
branches: [master] branches: [master]
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: write contents: write
jobs: jobs:
lint-markdown: lint-markdown:
name: Markdown Lint name: Markdown Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Run markdownlint - name: Run markdownlint
run: bash scripts/lint-markdown.sh run: bash scripts/lint-markdown.sh
detect-changes: detect-changes:
name: Detect Changes name: Detect Changes
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
src_changed: ${{ steps.check.outputs.src_changed }} src_changed: ${{ steps.check.outputs.src_changed }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Check for changes - name: Check for changes
id: check id: check
env: env:
BEFORE: ${{ github.event.before }} BEFORE: ${{ github.event.before }}
run: | run: |
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
# Initial push — treat everything as changed # Initial push — treat everything as changed
echo "src_changed=true" >> "$GITHUB_OUTPUT" echo "src_changed=true" >> "$GITHUB_OUTPUT"
else else
SRC_CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l) SRC_CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
[ "$SRC_CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT" [ "$SRC_CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
fi fi
test: test:
name: Build & Test name: Build & Test
needs: [lint-markdown, detect-changes] needs: [lint-markdown, detect-changes]
if: needs.detect-changes.outputs.src_changed == 'true' if: needs.detect-changes.outputs.src_changed == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup .NET 10 - name: Setup .NET 10
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- name: Cache NuGet packages - name: Cache NuGet packages
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.nuget/packages path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj') }} key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj') }}
restore-keys: nuget-${{ runner.os }}- restore-keys: nuget-${{ runner.os }}-
- name: Restore dependencies - name: Restore dependencies
run: dotnet restore src/EchoHub.slnx run: dotnet restore src/EchoHub.slnx
- name: Build - name: Build
run: dotnet build src/EchoHub.slnx --no-restore --configuration Release run: dotnet build src/EchoHub.slnx --no-restore --configuration Release
- name: Test - name: Test
run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal
release: release:
name: Create Release name: Create Release
needs: [lint-markdown, detect-changes, test] needs: [lint-markdown, detect-changes, test]
if: needs.detect-changes.outputs.src_changed == 'true' if: needs.detect-changes.outputs.src_changed == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup .NET 10 - name: Setup .NET 10
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- name: Read version - name: Read version
id: version id: version
run: | run: |
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props) VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if release exists - name: Check if release exists
id: check_release id: check_release
run: | run: |
if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT" echo "exists=true" >> "$GITHUB_OUTPUT"
else else
echo "exists=false" >> "$GITHUB_OUTPUT" echo "exists=false" >> "$GITHUB_OUTPUT"
fi fi
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Server win-x64 - name: Publish Server win-x64
if: steps.check_release.outputs.exists == 'false' if: steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64 run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64
- name: Publish Server linux-x64 - name: Publish Server linux-x64
if: steps.check_release.outputs.exists == 'false' if: steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64 run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64
- name: Publish Server osx-x64 - name: Publish Server osx-x64
if: steps.check_release.outputs.exists == 'false' if: steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64 run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64
- name: Publish Server osx-arm64 - name: Publish Server osx-arm64
if: steps.check_release.outputs.exists == 'false' if: steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true -o publish/server-osx-arm64 run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true -o publish/server-osx-arm64
- name: Publish Client win-x64 - name: Publish Client win-x64
if: steps.check_release.outputs.exists == 'false' if: steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64 run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64
- name: Publish Client linux-x64 - name: Publish Client linux-x64
if: steps.check_release.outputs.exists == 'false' if: 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 -o publish/client-linux-x64
- name: Publish Client osx-x64 - name: Publish Client osx-x64
if: steps.check_release.outputs.exists == 'false' if: 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 -o publish/client-osx-x64
- name: Publish Client osx-arm64 - name: Publish Client osx-arm64
if: steps.check_release.outputs.exists == 'false' if: steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -o publish/client-osx-arm64 run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -o publish/client-osx-arm64
- name: Zip artifacts - name: Zip artifacts
if: steps.check_release.outputs.exists == 'false' if: steps.check_release.outputs.exists == 'false'
run: | run: |
cd publish cd publish
zip -r ../EchoHub-Server-win-x64.zip server-win-x64/ zip -r ../EchoHub-Server-win-x64.zip server-win-x64/
zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/ zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/
zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/ zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/
zip -r ../EchoHub-Server-osx-arm64.zip server-osx-arm64/ zip -r ../EchoHub-Server-osx-arm64.zip server-osx-arm64/
zip -r ../EchoHub-Client-win-x64.zip client-win-x64/ zip -r ../EchoHub-Client-win-x64.zip client-win-x64/
zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/ zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/
zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/ zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/
zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/ zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/
- name: Create GitHub Release - name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false' if: steps.check_release.outputs.exists == 'false'
run: | run: |
gh release create "${{ steps.version.outputs.tag }}" \ gh release create "${{ steps.version.outputs.tag }}" \
--title "EchoHub ${{ steps.version.outputs.tag }}" \ --title "EchoHub ${{ steps.version.outputs.tag }}" \
--generate-notes \ --generate-notes \
EchoHub-Server-win-x64.zip \ EchoHub-Server-win-x64.zip \
EchoHub-Server-linux-x64.zip \ EchoHub-Server-linux-x64.zip \
EchoHub-Server-osx-x64.zip \ EchoHub-Server-osx-x64.zip \
EchoHub-Server-osx-arm64.zip \ EchoHub-Server-osx-arm64.zip \
EchoHub-Client-win-x64.zip \ EchoHub-Client-win-x64.zip \
EchoHub-Client-linux-x64.zip \ EchoHub-Client-linux-x64.zip \
EchoHub-Client-osx-x64.zip \ EchoHub-Client-osx-x64.zip \
EchoHub-Client-osx-arm64.zip EchoHub-Client-osx-arm64.zip
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+46 -46
View File
@@ -1,46 +1,46 @@
name: Docs name: Docs
on: on:
push: push:
branches: [master] branches: [master]
paths: paths:
- 'docs/**' - 'docs/**'
- 'src/**' - 'src/**'
permissions: permissions:
contents: write contents: write
jobs: jobs:
build-deploy: build-deploy:
name: Build & Deploy Docs name: Build & Deploy Docs
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup .NET 10 - name: Setup .NET 10
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- name: Cache NuGet packages - name: Cache NuGet packages
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.nuget/packages path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj') }} key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj') }}
restore-keys: nuget-${{ runner.os }}- restore-keys: nuget-${{ runner.os }}-
- name: Restore .NET tools - name: Restore .NET tools
run: dotnet tool restore run: dotnet tool restore
- name: Build solution - name: Build solution
run: dotnet build src/EchoHub.slnx --configuration Release run: dotnet build src/EchoHub.slnx --configuration Release
- name: Generate documentation - name: Generate documentation
run: dotnet docfx docs/docfx.json run: dotnet docfx docs/docfx.json
- name: Deploy to web branch - name: Deploy to web branch
uses: peaceiris/actions-gh-pages@v4 uses: peaceiris/actions-gh-pages@v4
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/_site publish_dir: ./docs/_site
publish_branch: web publish_branch: web
+65 -65
View File
@@ -1,65 +1,65 @@
name: PR Check name: PR Check
on: on:
pull_request: pull_request:
branches: [master] branches: [master]
jobs: jobs:
lint-markdown: lint-markdown:
name: Markdown Lint name: Markdown Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Run markdownlint - name: Run markdownlint
run: bash scripts/lint-markdown.sh run: bash scripts/lint-markdown.sh
test: test:
name: Build & Test name: Build & Test
needs: lint-markdown needs: lint-markdown
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Check for src/ changes - name: Check for src/ changes
id: changes id: changes
env: env:
BASE_REF: ${{ github.base_ref }} BASE_REF: ${{ github.base_ref }}
run: | run: |
git fetch origin "$BASE_REF" --depth=1 git fetch origin "$BASE_REF" --depth=1
CHANGED=$(git diff --name-only "origin/$BASE_REF"...HEAD -- 'src/' | wc -l) CHANGED=$(git diff --name-only "origin/$BASE_REF"...HEAD -- 'src/' | wc -l)
if [ "$CHANGED" -gt 0 ]; then if [ "$CHANGED" -gt 0 ]; then
echo "src_changed=true" >> "$GITHUB_OUTPUT" echo "src_changed=true" >> "$GITHUB_OUTPUT"
else else
echo "src_changed=false" >> "$GITHUB_OUTPUT" echo "src_changed=false" >> "$GITHUB_OUTPUT"
fi fi
- name: Setup .NET 10 - name: Setup .NET 10
if: steps.changes.outputs.src_changed == 'true' if: steps.changes.outputs.src_changed == 'true'
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- name: Cache NuGet packages - name: Cache NuGet packages
if: steps.changes.outputs.src_changed == 'true' if: steps.changes.outputs.src_changed == 'true'
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.nuget/packages path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj') }} key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj') }}
restore-keys: nuget-${{ runner.os }}- restore-keys: nuget-${{ runner.os }}-
- name: Restore dependencies - name: Restore dependencies
if: steps.changes.outputs.src_changed == 'true' if: steps.changes.outputs.src_changed == 'true'
run: dotnet restore src/EchoHub.slnx run: dotnet restore src/EchoHub.slnx
- name: Build - name: Build
if: steps.changes.outputs.src_changed == 'true' if: steps.changes.outputs.src_changed == 'true'
run: dotnet build src/EchoHub.slnx --no-restore --configuration Release run: dotnet build src/EchoHub.slnx --no-restore --configuration Release
- name: Test - name: Test
if: steps.changes.outputs.src_changed == 'true' if: steps.changes.outputs.src_changed == 'true'
run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal
- name: Skip notice - name: Skip notice
if: steps.changes.outputs.src_changed == 'false' if: steps.changes.outputs.src_changed == 'false'
run: echo "No changes in src/ — skipping build and test" run: echo "No changes in src/ — skipping build and test"
+32 -32
View File
@@ -1,32 +1,32 @@
# Code of Conduct # Code of Conduct
EchoHub is an open source project. We want it to be a welcoming place for everyone who wants to help. EchoHub is an open source project. We want it to be a welcoming place for everyone who wants to help.
## Our standards ## Our standards
We expect all participants (contributors, reviewers, and maintainers) to: We expect all participants (contributors, reviewers, and maintainers) to:
- Be respectful and constructive - Be respectful and constructive
- Assume good intent and communicate clearly - Assume good intent and communicate clearly
- Welcome differing viewpoints and experience levels - Welcome differing viewpoints and experience levels
- Focus on whats best for the community and the project - Focus on whats best for the community and the project
Unacceptable behavior includes: Unacceptable behavior includes:
- Harassment, discrimination, or personal attacks - Harassment, discrimination, or personal attacks
- Trolling, insults, or inflammatory comments - Trolling, insults, or inflammatory comments
- Publishing someone elses private information (doxxing) - Publishing someone elses private information (doxxing)
- Sexualized language or unwanted attention - Sexualized language or unwanted attention
## Enforcement ## Enforcement
Project maintainers may remove, edit, or reject contributions (issues, comments, PRs) that violate this Code of Conduct. Project maintainers may remove, edit, or reject contributions (issues, comments, PRs) that violate this Code of Conduct.
## Reporting ## Reporting
If you experience or witness unacceptable behavior: If you experience or witness unacceptable behavior:
- Use GitHubs built-in reporting tools where appropriate, and/or - Use GitHubs built-in reporting tools where appropriate, and/or
- Contact the project maintainer via GitHub: https://github.com/HueByte - Contact the project maintainer via GitHub: https://github.com/HueByte
Please include as much context as you can (links, screenshots, timestamps). Reports will be handled as discreetly as possible. Please include as much context as you can (links, screenshots, timestamps). Reports will be handled as discreetly as possible.
+101 -101
View File
@@ -1,101 +1,101 @@
# Contributing to EchoHub # Contributing to EchoHub
Thanks for your interest in contributing — EchoHub aims to stay lightweight, terminal-first, and self-hosted. Thanks for your interest in contributing — EchoHub aims to stay lightweight, terminal-first, and self-hosted.
## Quick start (dev) ## Quick start (dev)
### Prerequisites ### Prerequisites
- .NET 10 SDK - .NET 10 SDK
### Build ### Build
```bash ```bash
dotnet build src/EchoHub.slnx dotnet build src/EchoHub.slnx
``` ```
### Test ### Test
```bash ```bash
dotnet test src/EchoHub.slnx dotnet test src/EchoHub.slnx
``` ```
### Run (local) ### Run (local)
Server: Server:
```bash ```bash
dotnet run --project src/EchoHub.Server dotnet run --project src/EchoHub.Server
``` ```
Client: Client:
```bash ```bash
dotnet run --project src/EchoHub.Client dotnet run --project src/EchoHub.Client
``` ```
## What to work on ## What to work on
- Check open issues (especially `good first issue` / `help wanted` if present) - Check open issues (especially `good first issue` / `help wanted` if present)
- Docs fixes in `docs/` are always welcome - Docs fixes in `docs/` are always welcome
- Tests: `src/EchoHub.Tests/` - Tests: `src/EchoHub.Tests/`
If youre proposing a larger change, open an issue first so we can align on approach. If youre proposing a larger change, open an issue first so we can align on approach.
## Code style & expectations ## Code style & expectations
- Keep PRs focused (small and reviewable) - Keep PRs focused (small and reviewable)
- Prefer clear naming over cleverness - Prefer clear naming over cleverness
- Add/adjust tests for bug fixes when its practical - Add/adjust tests for bug fixes when its practical
- Avoid committing secrets (JWT secrets, tokens, connection strings) - Avoid committing secrets (JWT secrets, tokens, connection strings)
## Docs ## Docs
This repo uses DocFX for the site in `docs/`. This repo uses DocFX for the site in `docs/`.
To build docs locally you typically need the assemblies built in Release first: To build docs locally you typically need the assemblies built in Release first:
```bash ```bash
dotnet build src/EchoHub.slnx --configuration Release dotnet build src/EchoHub.slnx --configuration Release
``` ```
Then run DocFX: Then run DocFX:
```bash ```bash
docfx docs/docfx.json docfx docs/docfx.json
``` ```
## Markdown lint ## Markdown lint
CI lints Markdown. Locally: CI lints Markdown. Locally:
- On Linux/macOS (or Windows with Git Bash/WSL): - On Linux/macOS (or Windows with Git Bash/WSL):
```bash ```bash
./scripts/lint-markdown.sh ./scripts/lint-markdown.sh
``` ```
- Anywhere with Node.js installed: - Anywhere with Node.js installed:
```bash ```bash
npx --yes markdownlint-cli2 npx --yes markdownlint-cli2
``` ```
## Pull requests ## Pull requests
- Fill out the PR template - Fill out the PR template
- Ensure `dotnet test src/EchoHub.slnx` is green - Ensure `dotnet test src/EchoHub.slnx` is green
- Mention any behavioral changes (client UX, auth, uploads) - Mention any behavioral changes (client UX, auth, uploads)
## Commit messages ## Commit messages
Any consistent style is fine; descriptive subjects help reviews. Any consistent style is fine; descriptive subjects help reviews.
Examples: Examples:
- `fix(server): validate image magic bytes` - `fix(server): validate image magic bytes`
- `feat(client): add /servers improvements` - `feat(client): add /servers improvements`
- `docs: clarify getting started` - `docs: clarify getting started`
## Reporting security issues ## Reporting security issues
Please do **not** file public issues for security problems. See `SECURITY.md`. Please do **not** file public issues for security problems. See `SECURITY.md`.
+24 -24
View File
@@ -1,24 +1,24 @@
# Security Policy # Security Policy
## Reporting a vulnerability ## Reporting a vulnerability
If you believe youve found a security vulnerability in EchoHub (for example: auth bypass, token leakage, file upload validation bypass, RCE, etc.), please **do not** open a public GitHub issue. If you believe youve found a security vulnerability in EchoHub (for example: auth bypass, token leakage, file upload validation bypass, RCE, etc.), please **do not** open a public GitHub issue.
Preferred: use GitHubs private vulnerability reporting ("Report a vulnerability"): Preferred: use GitHubs private vulnerability reporting ("Report a vulnerability"):
- https://github.com/HueByte/EchoHub/security/advisories/new - https://github.com/HueByte/EchoHub/security/advisories/new
If that link is unavailable for your account, contact the maintainer via GitHub: If that link is unavailable for your account, contact the maintainer via GitHub:
- https://github.com/HueByte - https://github.com/HueByte
## What to include ## What to include
- A clear description of the issue and potential impact - A clear description of the issue and potential impact
- Reproduction steps or a proof-of-concept - Reproduction steps or a proof-of-concept
- Affected versions / commit SHA - Affected versions / commit SHA
- Any relevant logs (with secrets removed) - Any relevant logs (with secrets removed)
## Disclosure ## Disclosure
Ill acknowledge receipt, investigate, and work on a fix. Please avoid publicly disclosing details until a fix is available. Ill acknowledge receipt, investigate, and work on a fix. Please avoid publicly disclosing details until a fix is available.
+17 -17
View File
@@ -1,17 +1,17 @@
# API Reference # API Reference
Browse the generated API documentation for each EchoHub project. Browse the generated API documentation for each EchoHub project.
## Projects ## Projects
### Client ### Client
Terminal.Gui v2 TUI application -- UI components, services, themes, and configuration. Terminal.Gui v2 TUI application -- UI components, services, themes, and configuration.
### Core ### Core
Shared library -- DTOs, models, constants, and the SignalR client contract. Shared library -- DTOs, models, constants, and the SignalR client contract.
### Server ### Server
ASP.NET Core server -- controllers, hubs, authentication, and data access. ASP.NET Core server -- controllers, hubs, authentication, and data access.
+18 -18
View File
@@ -1,18 +1,18 @@
- name: Client - name: Client
items: items:
- name: API Reference - name: API Reference
href: ../_api_meta/client/toc.yml href: ../_api_meta/client/toc.yml
- name: Articles - name: Articles
href: client-articles/ href: client-articles/
- name: Core - name: Core
items: items:
- name: API Reference - name: API Reference
href: ../_api_meta/core/toc.yml href: ../_api_meta/core/toc.yml
- name: Articles - name: Articles
href: core-articles/ href: core-articles/
- name: Server - name: Server
items: items:
- name: API Reference - name: API Reference
href: ../_api_meta/server/toc.yml href: ../_api_meta/server/toc.yml
- name: Articles - name: Articles
href: server-articles/ href: server-articles/
+41 -41
View File
@@ -1,41 +1,41 @@
# Architecture # Architecture
## Overview ## Overview
EchoHub follows a decentralized model where each server is fully independent. There is no central authority or account federation. Users create one account per server. EchoHub follows a decentralized model where each server is fully independent. There is no central authority or account federation. Users create one account per server.
## Components ## Components
### EchoHub.Core ### EchoHub.Core
Shared library containing: Shared library containing:
- **Models**: `User`, `Channel`, `Message`, `RefreshToken` - **Models**: `User`, `Channel`, `Message`, `RefreshToken`
- **DTOs**: Record types for API requests/responses - **DTOs**: Record types for API requests/responses
- **Contracts**: `IEchoHubClient` -- the strongly-typed SignalR client interface - **Contracts**: `IEchoHubClient` -- the strongly-typed SignalR client interface
- **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants` - **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants`
### EchoHub.Server ### EchoHub.Server
ASP.NET Core web application: ASP.NET Core web application:
- **Controllers**: REST API endpoints for auth, channels, users, files, server info - **Controllers**: REST API endpoints for auth, channels, users, files, server info
- **Hubs**: SignalR `ChatHub` for real-time messaging - **Hubs**: SignalR `ChatHub` for real-time messaging
- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens) - **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens)
- **Data**: EF Core with SQLite - **Data**: EF Core with SQLite
- **Services**: Presence tracking, file storage, image-to-ASCII conversion - **Services**: Presence tracking, file storage, image-to-ASCII conversion
### EchoHub.Client ### EchoHub.Client
Terminal.Gui v2 TUI application: Terminal.Gui v2 TUI application:
- **UI**: Main window, dialogs, chat renderer with ANSI color support - **UI**: Main window, dialogs, chat renderer with ANSI color support
- **Services**: API client with automatic token refresh, SignalR connection wrapper - **Services**: API client with automatic token refresh, SignalR connection wrapper
- **Themes**: 6 built-in color themes - **Themes**: 6 built-in color themes
- **Config**: Client configuration management - **Config**: Client configuration management
## Communication ## Communication
- REST API for authentication, profile management, channel CRUD, file uploads - REST API for authentication, profile management, channel CRUD, file uploads
- SignalR WebSocket for real-time messaging and presence updates - SignalR WebSocket for real-time messaging and presence updates
- JWT tokens passed via query string for SignalR authentication - JWT tokens passed via query string for SignalR authentication
+31 -31
View File
@@ -1,31 +1,31 @@
# Getting Started # Getting Started
## Prerequisites ## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download) - [.NET 10 SDK](https://dotnet.microsoft.com/download)
## Run the Server ## Run the Server
```bash ```bash
dotnet run --project src/EchoHub.Server dotnet run --project src/EchoHub.Server
``` ```
On first run, the server automatically: On first run, the server automatically:
1. Creates `appsettings.json` from the example config 1. Creates `appsettings.json` from the example config
2. Generates a secure JWT secret 2. Generates a secure JWT secret
3. Creates the SQLite database with a `#general` channel 3. Creates the SQLite database with a `#general` channel
## Run the Client ## Run the Client
```bash ```bash
dotnet run --project src/EchoHub.Client dotnet run --project src/EchoHub.Client
``` ```
Connect to a server, register an account, and start chatting. Connect to a server, register an account, and start chatting.
## Build from Source ## Build from Source
```bash ```bash
dotnet build src/EchoHub.slnx dotnet build src/EchoHub.slnx
``` ```
+4 -4
View File
@@ -1,4 +1,4 @@
- name: Getting Started - name: Getting Started
href: getting-started.md href: getting-started.md
- name: Architecture - name: Architecture
href: architecture.md href: architecture.md
+4 -4
View File
@@ -1,4 +1,4 @@
- name: Overview - name: Overview
href: index.md href: index.md
- name: v0.1.0 - name: v0.1.0
href: v0.1.0.md href: v0.1.0.md
+13 -13
View File
@@ -1,13 +1,13 @@
# v0.1.0 - Initial Release # v0.1.0 - Initial Release
## Features ## Features
- Real-time messaging via SignalR - Real-time messaging via SignalR
- JWT authentication with access and refresh tokens - JWT authentication with access and refresh tokens
- Channel management (create, set topic, delete) - Channel management (create, set topic, delete)
- File and image uploads with magic-byte validation - File and image uploads with magic-byte validation
- Image-to-ASCII conversion for terminal display - Image-to-ASCII conversion for terminal display
- Presence tracking (online, away, DND, invisible) - Presence tracking (online, away, DND, invisible)
- 6 built-in TUI themes - 6 built-in TUI themes
- Rate limiting on auth, upload, and general endpoints - Rate limiting on auth, upload, and general endpoints
- Self-contained binary releases for Windows, Linux, and macOS - Self-contained binary releases for Windows, Linux, and macOS
+83 -83
View File
@@ -1,83 +1,83 @@
{ {
"$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json",
"metadata": [ "metadata": [
{ {
"src": [ "src": [
{ {
"src": "../src/EchoHub.Core/bin/Release/net10.0", "src": "../src/EchoHub.Core/bin/Release/net10.0",
"files": ["EchoHub.Core.dll"] "files": ["EchoHub.Core.dll"]
} }
], ],
"dest": "_api_meta/core", "dest": "_api_meta/core",
"filter": "filterConfig.yml" "filter": "filterConfig.yml"
}, },
{ {
"src": [ "src": [
{ {
"src": "../src/EchoHub.Server/bin/Release/net10.0", "src": "../src/EchoHub.Server/bin/Release/net10.0",
"files": ["EchoHub.Server.dll"] "files": ["EchoHub.Server.dll"]
} }
], ],
"dest": "_api_meta/server", "dest": "_api_meta/server",
"filter": "filterConfig.yml" "filter": "filterConfig.yml"
}, },
{ {
"src": [ "src": [
{ {
"src": "../src/EchoHub.Client/bin/Release/net10.0", "src": "../src/EchoHub.Client/bin/Release/net10.0",
"files": ["EchoHub.Client.dll"] "files": ["EchoHub.Client.dll"]
} }
], ],
"dest": "_api_meta/client", "dest": "_api_meta/client",
"filter": "filterConfig.yml" "filter": "filterConfig.yml"
} }
], ],
"build": { "build": {
"content": [ "content": [
{ {
"files": ["**/*.{md,yml}"], "files": ["**/*.{md,yml}"],
"exclude": ["_site/**", "_api_meta/**"] "exclude": ["_site/**", "_api_meta/**"]
}, },
{ {
"src": "_api_meta/core", "src": "_api_meta/core",
"dest": "api/core", "dest": "api/core",
"files": ["*.yml"], "files": ["*.yml"],
"exclude": ["toc.yml"] "exclude": ["toc.yml"]
}, },
{ {
"src": "_api_meta/server", "src": "_api_meta/server",
"dest": "api/server", "dest": "api/server",
"files": ["*.yml"], "files": ["*.yml"],
"exclude": ["toc.yml"] "exclude": ["toc.yml"]
}, },
{ {
"src": "_api_meta/client", "src": "_api_meta/client",
"dest": "api/client", "dest": "api/client",
"files": ["*.yml"], "files": ["*.yml"],
"exclude": ["toc.yml"] "exclude": ["toc.yml"]
} }
], ],
"resource": [ "resource": [
{ {
"files": ["images/**"] "files": ["images/**"]
} }
], ],
"output": "_site", "output": "_site",
"template": ["default", "modern", "templates/echohub"], "template": ["default", "modern", "templates/echohub"],
"globalMetadata": { "globalMetadata": {
"_appName": "EchoHub", "_appName": "EchoHub",
"_appTitle": "EchoHub Documentation", "_appTitle": "EchoHub Documentation",
"_appLogoPath": "images/hue_icon.svg", "_appLogoPath": "images/hue_icon.svg",
"_appFaviconPath": "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'>&middot;</span><a href='https://github.com/HueByte/EchoHub'>GitHub</a><span class='footer-sep'>&middot;</span><a href='https://echohub.voidcube.cloud'>Website</a><span class='footer-sep'>&middot;</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'>&middot;</span><a href='https://github.com/HueByte/EchoHub'>GitHub</a><span class='footer-sep'>&middot;</span><a href='https://echohub.voidcube.cloud'>Website</a><span class='footer-sep'>&middot;</span><span class='footer-credit'>Built with <a href='https://dotnet.github.io/docfx'>DocFX</a></span></div></div>",
"_enableSearch": true, "_enableSearch": true,
"_disableContribution": false, "_disableContribution": false,
"_gitContribute": { "_gitContribute": {
"repo": "https://github.com/HueByte/EchoHub", "repo": "https://github.com/HueByte/EchoHub",
"branch": "master", "branch": "master",
"path": "docs" "path": "docs"
} }
}, },
"markdownEngineName": "markdig" "markdownEngineName": "markdig"
} }
} }
+14 -14
View File
@@ -1,14 +1,14 @@
--- ---
_layout: landing _layout: landing
--- ---
# EchoHub Documentation # EchoHub Documentation
Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-like chat application built with .NET 10 and SignalR. Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-like chat application built with .NET 10 and SignalR.
## Quick Links ## Quick Links
- [Getting Started](articles/getting-started.md) - Set up and run EchoHub - [Getting Started](articles/getting-started.md) - Set up and run EchoHub
- [Architecture](articles/architecture.md) - Understand the system design - [Architecture](articles/architecture.md) - Understand the system design
- [API Reference](api/index.md) - Generated C# API documentation - [API Reference](api/index.md) - Generated C# API documentation
- [Changelog](changelog/index.md) - Release history - [Changelog](changelog/index.md) - Release history
+9 -9
View File
@@ -1,9 +1,9 @@
- name: Articles - name: Articles
href: articles/ href: articles/
homepage: articles/getting-started.md homepage: articles/getting-started.md
- name: Changelog - name: Changelog
href: changelog/ href: changelog/
homepage: changelog/index.md homepage: changelog/index.md
- name: API - name: API
href: api/ href: api/
homepage: api/index.md homepage: api/index.md
+7 -7
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.1.0</Version> <Version>0.1.0</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn> <NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
File diff suppressed because it is too large Load Diff
+32 -32
View File
@@ -1,32 +1,32 @@
using Serilog; using Serilog;
using Terminal.Gui.App; using Terminal.Gui.App;
namespace EchoHub.Client; namespace EchoHub.Client;
/// <summary> /// <summary>
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate. /// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
/// Runs async work on a background thread and routes exceptions to the UI. /// Runs async work on a background thread and routes exceptions to the UI.
/// </summary> /// </summary>
public static class AsyncRunner public static class AsyncRunner
{ {
public static void Run( public static void Run(
IApplication app, IApplication app,
Func<Task> work, Func<Task> work,
Action<string> showError, Action<string> showError,
string errorPrefix, string errorPrefix,
string? logContext = null) string? logContext = null)
{ {
Task.Run(async () => Task.Run(async () =>
{ {
try try
{ {
await work(); await work();
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, "{Context} failed", logContext ?? errorPrefix); Log.Error(ex, "{Context} failed", logContext ?? errorPrefix);
app.Invoke(() => showError($"{errorPrefix}: {ex.Message}")); app.Invoke(() => showError($"{errorPrefix}: {ex.Message}"));
} }
}); });
} }
} }
+32 -32
View File
@@ -1,32 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" /> <ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
<PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5027" /> <PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5027" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="appsettings.json" Condition="Exists('appsettings.json')"> <Content Include="appsettings.json" Condition="Exists('appsettings.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<EmbeddedResource Include="appsettings.example.json"> <EmbeddedResource Include="appsettings.example.json">
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName> <LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+56 -56
View File
@@ -1,56 +1,56 @@
using EchoHub.Client; using EchoHub.Client;
using EchoHub.Client.Config; using EchoHub.Client.Config;
using EchoHub.Client.Themes; using EchoHub.Client.Themes;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Serilog; using Serilog;
using Terminal.Gui.App; using Terminal.Gui.App;
var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(appSettingsPath)) if (!File.Exists(appSettingsPath))
{ {
using var stream = typeof(AppOrchestrator).Assembly using var stream = typeof(AppOrchestrator).Assembly
.GetManifestResourceStream("EchoHub.Client.appsettings.example.json"); .GetManifestResourceStream("EchoHub.Client.appsettings.example.json");
if (stream is not null) if (stream is not null)
{ {
using var file = File.Create(appSettingsPath); using var file = File.Create(appSettingsPath);
stream.CopyTo(file); stream.CopyTo(file);
} }
} }
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory) .SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
.Build(); .Build();
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration) .ReadFrom.Configuration(configuration)
.CreateLogger(); .CreateLogger();
Log.Information("EchoHub client starting"); Log.Information("EchoHub client starting");
try try
{ {
var config = ConfigManager.Load(); var config = ConfigManager.Load();
Log.Information("Configuration loaded, active theme: {Theme}", config.ActiveTheme); Log.Information("Configuration loaded, active theme: {Theme}", config.ActiveTheme);
var app = Application.Create().Init(); var app = Application.Create().Init();
var theme = ThemeManager.GetTheme(config.ActiveTheme); var theme = ThemeManager.GetTheme(config.ActiveTheme);
ThemeManager.ApplyTheme(theme); ThemeManager.ApplyTheme(theme);
using var orchestrator = new AppOrchestrator(app, config); using var orchestrator = new AppOrchestrator(app, config);
app.Run(orchestrator.MainWindow); app.Run(orchestrator.MainWindow);
app.Dispose(); app.Dispose();
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Fatal(ex, "EchoHub client crashed"); Log.Fatal(ex, "EchoHub client crashed");
throw; throw;
} }
finally finally
{ {
Log.Information("EchoHub client shutting down"); Log.Information("EchoHub client shutting down");
Log.CloseAndFlush(); Log.CloseAndFlush();
} }
+280 -280
View File
@@ -1,280 +1,280 @@
using System.Collections; using System.Collections;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Terminal.Gui.Drawing; using Terminal.Gui.Drawing;
using Terminal.Gui.Views; using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute; using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI; namespace EchoHub.Client.UI;
/// <summary> /// <summary>
/// A colored text segment within a chat line. /// A colored text segment within a chat line.
/// </summary> /// </summary>
public record ChatSegment(string Text, Attribute? Color); public record ChatSegment(string Text, Attribute? Color);
/// <summary> /// <summary>
/// A single line in the chat, composed of colored segments. /// A single line in the chat, composed of colored segments.
/// </summary> /// </summary>
public partial class ChatLine public partial class ChatLine
{ {
public List<ChatSegment> Segments { get; } public List<ChatSegment> Segments { get; }
public int TextLength { get; } public int TextLength { get; }
public ChatLine(string plainText) public ChatLine(string plainText)
{ {
Segments = [new ChatSegment(plainText, null)]; Segments = [new ChatSegment(plainText, null)];
TextLength = plainText.Length; TextLength = plainText.Length;
} }
public ChatLine(List<ChatSegment> segments) public ChatLine(List<ChatSegment> segments)
{ {
Segments = segments; Segments = segments;
TextLength = segments.Sum(s => s.Text.Length); TextLength = segments.Sum(s => s.Text.Length);
} }
public override string ToString() => string.Concat(Segments.Select(s => s.Text)); public override string ToString() => string.Concat(Segments.Select(s => s.Text));
/// <summary> /// <summary>
/// Wrap this line into multiple lines that fit within the given width. /// Wrap this line into multiple lines that fit within the given width.
/// Continuation lines are indented with the specified number of spaces. /// Continuation lines are indented with the specified number of spaces.
/// </summary> /// </summary>
public List<ChatLine> Wrap(int width, int continuationIndent = 0) public List<ChatLine> Wrap(int width, int continuationIndent = 0)
{ {
if (width <= 0 || TextLength <= width) if (width <= 0 || TextLength <= width)
return [this]; return [this];
var results = new List<ChatLine>(); var results = new List<ChatLine>();
var currentSegments = new List<ChatSegment>(); var currentSegments = new List<ChatSegment>();
int col = 0; int col = 0;
foreach (var segment in Segments) foreach (var segment in Segments)
{ {
int segPos = 0; int segPos = 0;
while (segPos < segment.Text.Length) while (segPos < segment.Text.Length)
{ {
int remaining = width - col; int remaining = width - col;
if (remaining <= 0) if (remaining <= 0)
{ {
// Emit current line and start a new one // Emit current line and start a new one
results.Add(new ChatLine(currentSegments)); results.Add(new ChatLine(currentSegments));
currentSegments = []; currentSegments = [];
// Add indent for continuation // Add indent for continuation
if (continuationIndent > 0) if (continuationIndent > 0)
{ {
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null)); currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
col = continuationIndent; col = continuationIndent;
} }
else else
{ {
col = 0; col = 0;
} }
remaining = width - col; remaining = width - col;
} }
int take = Math.Min(segment.Text.Length - segPos, remaining); int take = Math.Min(segment.Text.Length - segPos, remaining);
currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color)); currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color));
col += take; col += take;
segPos += take; segPos += take;
} }
} }
if (currentSegments.Count > 0) if (currentSegments.Count > 0)
results.Add(new ChatLine(currentSegments)); results.Add(new ChatLine(currentSegments));
return results; return results;
} }
/// <summary> /// <summary>
/// Parse a string containing ANSI 24-bit color escape codes into colored segments. /// Parse a string containing ANSI 24-bit color escape codes into colored segments.
/// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset) /// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset)
/// </summary> /// </summary>
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null) public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
{ {
var segments = new List<ChatSegment>(); var segments = new List<ChatSegment>();
var regex = AnsiColorRegex(); var regex = AnsiColorRegex();
int lastIndex = 0; int lastIndex = 0;
Attribute? currentColor = defaultAttr; Attribute? currentColor = defaultAttr;
foreach (Match match in regex.Matches(ansiText)) foreach (Match match in regex.Matches(ansiText))
{ {
// Add any text before this escape sequence // Add any text before this escape sequence
if (match.Index > lastIndex) if (match.Index > lastIndex)
{ {
var text = ansiText[lastIndex..match.Index]; var text = ansiText[lastIndex..match.Index];
if (text.Length > 0) if (text.Length > 0)
segments.Add(new ChatSegment(text, currentColor)); segments.Add(new ChatSegment(text, currentColor));
} }
// Parse the escape sequence // Parse the escape sequence
if (match.Groups[1].Value == "0") if (match.Groups[1].Value == "0")
{ {
// Reset // Reset
currentColor = defaultAttr; currentColor = defaultAttr;
} }
else if (match.Groups[2].Success) else if (match.Groups[2].Success)
{ {
// 38;2;R;G;B — 24-bit foreground color // 38;2;R;G;B — 24-bit foreground color
var r = int.Parse(match.Groups[3].Value); var r = int.Parse(match.Groups[3].Value);
var g = int.Parse(match.Groups[4].Value); var g = int.Parse(match.Groups[4].Value);
var b = int.Parse(match.Groups[5].Value); var b = int.Parse(match.Groups[5].Value);
currentColor = new Attribute(new Color(r, g, b), Color.Black); currentColor = new Attribute(new Color(r, g, b), Color.Black);
} }
lastIndex = match.Index + match.Length; lastIndex = match.Index + match.Length;
} }
// Add remaining text // Add remaining text
if (lastIndex < ansiText.Length) if (lastIndex < ansiText.Length)
{ {
var text = ansiText[lastIndex..]; var text = ansiText[lastIndex..];
if (text.Length > 0) if (text.Length > 0)
segments.Add(new ChatSegment(text, currentColor)); segments.Add(new ChatSegment(text, currentColor));
} }
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine(""); return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
} }
// Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground) // Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground)
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex(); private static partial Regex AnsiColorRegex();
} }
/// <summary> /// <summary>
/// Custom list data source for chat messages with per-character coloring. /// Custom list data source for chat messages with per-character coloring.
/// </summary> /// </summary>
public class ChatListSource : IListDataSource public class ChatListSource : IListDataSource
{ {
private readonly List<ChatLine> _lines = []; private readonly List<ChatLine> _lines = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged; public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _lines.Count; public int Count => _lines.Count;
public int MaxItemLength { get; private set; } public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; } public bool SuspendCollectionChangedEvent { get; set; }
public void Add(ChatLine line) public void Add(ChatLine line)
{ {
_lines.Add(line); _lines.Add(line);
UpdateMaxLength(line); UpdateMaxLength(line);
RaiseCollectionChanged(); RaiseCollectionChanged();
} }
public void AddRange(IEnumerable<ChatLine> lines) public void AddRange(IEnumerable<ChatLine> lines)
{ {
foreach (var line in lines) foreach (var line in lines)
{ {
_lines.Add(line); _lines.Add(line);
UpdateMaxLength(line); UpdateMaxLength(line);
} }
RaiseCollectionChanged(); RaiseCollectionChanged();
} }
public void InsertRange(int index, IEnumerable<ChatLine> lines) public void InsertRange(int index, IEnumerable<ChatLine> lines)
{ {
var items = lines.ToList(); var items = lines.ToList();
_lines.InsertRange(index, items); _lines.InsertRange(index, items);
foreach (var line in items) foreach (var line in items)
UpdateMaxLength(line); UpdateMaxLength(line);
RaiseCollectionChanged(); RaiseCollectionChanged();
} }
public void Clear() public void Clear()
{ {
_lines.Clear(); _lines.Clear();
MaxItemLength = 0; MaxItemLength = 0;
RaiseCollectionChanged(); RaiseCollectionChanged();
} }
public bool IsMarked(int item) => false; public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { } public void SetMark(int item, bool value) { }
public IList ToList() => _lines.Select(l => l.ToString()).ToList(); public IList ToList() => _lines.Select(l => l.ToString()).ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0) public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{ {
listView.Move(Math.Max(col - viewportX, 0), row); listView.Move(Math.Max(col - viewportX, 0), row);
var chatLine = _lines[item]; var chatLine = _lines[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
int charPos = 0; int charPos = 0;
int drawnChars = 0; int drawnChars = 0;
foreach (var segment in chatLine.Segments) foreach (var segment in chatLine.Segments)
{ {
var attr = segment.Color ?? normalAttr; var attr = segment.Color ?? normalAttr;
listView.SetAttribute(attr); listView.SetAttribute(attr);
foreach (var ch in segment.Text) foreach (var ch in segment.Text)
{ {
if (charPos >= viewportX && drawnChars < width) if (charPos >= viewportX && drawnChars < width)
{ {
listView.AddRune(new Rune(ch)); listView.AddRune(new Rune(ch));
drawnChars++; drawnChars++;
} }
charPos++; charPos++;
} }
} }
// Fill remaining width with spaces using default colors // Fill remaining width with spaces using default colors
listView.SetAttribute(normalAttr); listView.SetAttribute(normalAttr);
while (drawnChars < width) while (drawnChars < width)
{ {
listView.AddRune(new Rune(' ')); listView.AddRune(new Rune(' '));
drawnChars++; drawnChars++;
} }
} }
private void UpdateMaxLength(ChatLine line) private void UpdateMaxLength(ChatLine line)
{ {
if (line.TextLength > MaxItemLength) if (line.TextLength > MaxItemLength)
MaxItemLength = line.TextLength; MaxItemLength = line.TextLength;
} }
private void RaiseCollectionChanged() private void RaiseCollectionChanged()
{ {
if (!SuspendCollectionChangedEvent) if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
} }
public void Dispose() { } public void Dispose() { }
} }
/// <summary> /// <summary>
/// Shared color attributes for chat rendering (timestamps, system messages). /// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary> /// </summary>
public static class ChatColors public static class ChatColors
{ {
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black); public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black); public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
} }
/// <summary> /// <summary>
/// Helper to parse hex colors to Terminal.Gui Attributes. /// Helper to parse hex colors to Terminal.Gui Attributes.
/// </summary> /// </summary>
public static class ColorHelper public static class ColorHelper
{ {
public static Attribute? ParseHexColor(string? hex) public static Attribute? ParseHexColor(string? hex)
{ {
if (string.IsNullOrWhiteSpace(hex)) if (string.IsNullOrWhiteSpace(hex))
return null; return null;
hex = hex.TrimStart('#'); hex = hex.TrimStart('#');
if (hex.Length != 6) if (hex.Length != 6)
return null; return null;
try try
{ {
var r = Convert.ToInt32(hex[..2], 16); var r = Convert.ToInt32(hex[..2], 16);
var g = Convert.ToInt32(hex[2..4], 16); var g = Convert.ToInt32(hex[2..4], 16);
var b = Convert.ToInt32(hex[4..6], 16); var b = Convert.ToInt32(hex[4..6], 16);
return new Attribute(new Color(r, g, b), Color.Black); return new Attribute(new Color(r, g, b), Color.Black);
} }
catch catch
{ {
return null; return null;
} }
} }
} }
+77 -77
View File
@@ -1,77 +1,77 @@
using Terminal.Gui.App; using Terminal.Gui.App;
using Terminal.Gui.Views; using Terminal.Gui.Views;
using Terminal.Gui.ViewBase; using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI; namespace EchoHub.Client.UI;
public record CreateChannelResult(string Name, string? Topic); public record CreateChannelResult(string Name, string? Topic);
public sealed class CreateChannelDialog public sealed class CreateChannelDialog
{ {
public static CreateChannelResult? Show(IApplication app) public static CreateChannelResult? Show(IApplication app)
{ {
CreateChannelResult? result = null; CreateChannelResult? result = null;
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 }; var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 };
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 }; var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) }; var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 }; var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) }; var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
var hintLabel = new Label var hintLabel = new Label
{ {
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)", Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
X = 1, X = 1,
Y = 5, Y = 5,
}; };
var createButton = new Button var createButton = new Button
{ {
Text = "Create", Text = "Create",
IsDefault = true, IsDefault = true,
X = Pos.Center() - 10, X = Pos.Center() - 10,
Y = 7 Y = 7
}; };
var cancelButton = new Button var cancelButton = new Button
{ {
Text = "Cancel", Text = "Cancel",
X = Pos.Center() + 5, X = Pos.Center() + 5,
Y = 7 Y = 7
}; };
createButton.Accepting += (s, e) => createButton.Accepting += (s, e) =>
{ {
var name = nameField.Text?.Trim().ToLowerInvariant(); var name = nameField.Text?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
{ {
MessageBox.ErrorQuery(app, "Error", "Channel name is required.", "OK"); MessageBox.ErrorQuery(app, "Error", "Channel name is required.", "OK");
return; return;
} }
var topic = topicField.Text?.Trim(); var topic = topicField.Text?.Trim();
if (string.IsNullOrWhiteSpace(topic)) if (string.IsNullOrWhiteSpace(topic))
topic = null; topic = null;
result = new CreateChannelResult(name, topic); result = new CreateChannelResult(name, topic);
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
cancelButton.Accepting += (s, e) => cancelButton.Accepting += (s, e) =>
{ {
result = null; result = null;
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton); dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton);
nameField.SetFocus(); nameField.SetFocus();
app.Run(dialog); app.Run(dialog);
return result; return result;
} }
} }
+246 -246
View File
@@ -1,246 +1,246 @@
using Terminal.Gui.App; using Terminal.Gui.App;
using Terminal.Gui.Views; using Terminal.Gui.Views;
using Terminal.Gui.ViewBase; using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing; using Terminal.Gui.Drawing;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using Attribute = Terminal.Gui.Drawing.Attribute; using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI; namespace EchoHub.Client.UI;
/// <summary> /// <summary>
/// Action selected by the user in their own profile dialog. /// Action selected by the user in their own profile dialog.
/// </summary> /// </summary>
public enum ProfileAction public enum ProfileAction
{ {
Close, Close,
EditProfile, EditProfile,
SetStatus SetStatus
} }
/// <summary> /// <summary>
/// Dialog for viewing a user's server profile. /// Dialog for viewing a user's server profile.
/// Shows Edit Profile / Set Status buttons when viewing own profile. /// Shows Edit Profile / Set Status buttons when viewing own profile.
/// </summary> /// </summary>
public sealed class ProfileViewDialog public sealed class ProfileViewDialog
{ {
/// <summary> /// <summary>
/// Show a read-only profile view for another user. /// Show a read-only profile view for another user.
/// </summary> /// </summary>
public static void Show(IApplication app, UserProfileDto? profile) public static void Show(IApplication app, UserProfileDto? profile)
{ {
ShowInternal(app, profile, isOwnProfile: false); ShowInternal(app, profile, isOwnProfile: false);
} }
/// <summary> /// <summary>
/// Show the profile view for the current user with action buttons. /// Show the profile view for the current user with action buttons.
/// Returns the action the user selected. /// Returns the action the user selected.
/// </summary> /// </summary>
public static ProfileAction ShowOwn( public static ProfileAction ShowOwn(
IApplication app, IApplication app,
UserProfileDto? profile, UserProfileDto? profile,
UserStatus currentStatus, UserStatus currentStatus,
string? currentStatusMessage) string? currentStatusMessage)
{ {
return ShowInternal(app, profile, isOwnProfile: true, currentStatus, currentStatusMessage); return ShowInternal(app, profile, isOwnProfile: true, currentStatus, currentStatusMessage);
} }
private static ProfileAction ShowInternal( private static ProfileAction ShowInternal(
IApplication app, IApplication app,
UserProfileDto? profile, UserProfileDto? profile,
bool isOwnProfile, bool isOwnProfile,
UserStatus? currentStatus = null, UserStatus? currentStatus = null,
string? currentStatusMessage = null) string? currentStatusMessage = null)
{ {
if (profile is null) if (profile is null)
{ {
MessageBox.ErrorQuery(app, "Profile", "User not found.", "OK"); MessageBox.ErrorQuery(app, "Profile", "User not found.", "OK");
return ProfileAction.Close; return ProfileAction.Close;
} }
var action = ProfileAction.Close; var action = ProfileAction.Close;
var dialog = new Dialog var dialog = new Dialog
{ {
Title = isOwnProfile ? "My Profile" : $"Profile \u2014 {profile.Username}", Title = isOwnProfile ? "My Profile" : $"Profile \u2014 {profile.Username}",
Width = 50, Width = 50,
Height = 20 Height = 20
}; };
int row = 0; int row = 0;
// Username // Username
var usernameLabel = new Label { Text = "Username:", X = 1, Y = row }; var usernameLabel = new Label { Text = "Username:", X = 1, Y = row };
var usernameValue = new Label { Text = profile.Username, X = 14, Y = row }; var usernameValue = new Label { Text = profile.Username, X = 14, Y = row };
usernameValue.SetScheme(new Scheme usernameValue.SetScheme(new Scheme
{ {
Normal = new Attribute(Color.BrightYellow, Color.Blue) Normal = new Attribute(Color.BrightYellow, Color.Blue)
}); });
dialog.Add(usernameLabel, usernameValue); dialog.Add(usernameLabel, usernameValue);
row++; row++;
// Display Name // Display Name
var nameLabel = new Label { Text = "Name:", X = 1, Y = row }; var nameLabel = new Label { Text = "Name:", X = 1, Y = row };
var nameValue = new Label { Text = profile.DisplayName ?? "-", X = 14, Y = row }; var nameValue = new Label { Text = profile.DisplayName ?? "-", X = 14, Y = row };
dialog.Add(nameLabel, nameValue); dialog.Add(nameLabel, nameValue);
row++; row++;
// Status — use live status for own profile, stored status for others // Status — use live status for own profile, stored status for others
var displayStatus = isOwnProfile && currentStatus.HasValue ? currentStatus.Value : profile.Status; var displayStatus = isOwnProfile && currentStatus.HasValue ? currentStatus.Value : profile.Status;
var displayStatusMsg = isOwnProfile ? currentStatusMessage : profile.StatusMessage; var displayStatusMsg = isOwnProfile ? currentStatusMessage : profile.StatusMessage;
var statusLabel = new Label { Text = "Status:", X = 1, Y = row }; var statusLabel = new Label { Text = "Status:", X = 1, Y = row };
var statusText = FormatStatus(displayStatus); var statusText = FormatStatus(displayStatus);
var statusValue = new Label { Text = statusText, X = 14, Y = row }; var statusValue = new Label { Text = statusText, X = 14, Y = row };
statusValue.SetScheme(new Scheme statusValue.SetScheme(new Scheme
{ {
Normal = new Attribute(GetStatusColor(displayStatus), Color.Blue) Normal = new Attribute(GetStatusColor(displayStatus), Color.Blue)
}); });
dialog.Add(statusLabel, statusValue); dialog.Add(statusLabel, statusValue);
row++; row++;
// Status Message // Status Message
if (!string.IsNullOrWhiteSpace(displayStatusMsg)) if (!string.IsNullOrWhiteSpace(displayStatusMsg))
{ {
var msgLabel = new Label { Text = "Message:", X = 1, Y = row }; var msgLabel = new Label { Text = "Message:", X = 1, Y = row };
var msgValue = new Label { Text = displayStatusMsg, X = 14, Y = row, Width = Dim.Fill(2) }; var msgValue = new Label { Text = displayStatusMsg, X = 14, Y = row, Width = Dim.Fill(2) };
dialog.Add(msgLabel, msgValue); dialog.Add(msgLabel, msgValue);
row++; row++;
} }
// Color // Color
var colorLabel = new Label { Text = "Color:", X = 1, Y = row }; var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row }; var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
if (ColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr) if (ColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
colorValue.SetScheme(new Scheme { Normal = colorAttr }); colorValue.SetScheme(new Scheme { Normal = colorAttr });
dialog.Add(colorLabel, colorValue); dialog.Add(colorLabel, colorValue);
row++; row++;
// Bio // Bio
row++; row++;
var bioLabel = new Label { Text = "Bio:", X = 1, Y = row }; var bioLabel = new Label { Text = "Bio:", X = 1, Y = row };
dialog.Add(bioLabel); dialog.Add(bioLabel);
row++; row++;
var bioView = new TextView var bioView = new TextView
{ {
X = 1, X = 1,
Y = row, Y = row,
Width = Dim.Fill(2), Width = Dim.Fill(2),
Height = 3, Height = 3,
Text = profile.Bio ?? "-", Text = profile.Bio ?? "-",
ReadOnly = true, ReadOnly = true,
WordWrap = true WordWrap = true
}; };
bioView.SetScheme(new Scheme bioView.SetScheme(new Scheme
{ {
Normal = new Attribute(Color.White, Color.DarkGray), Normal = new Attribute(Color.White, Color.DarkGray),
Focus = new Attribute(Color.White, Color.DarkGray) Focus = new Attribute(Color.White, Color.DarkGray)
}); });
dialog.Add(bioView); dialog.Add(bioView);
row += 3; row += 3;
// ASCII Avatar // ASCII Avatar
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii)) if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
{ {
row++; row++;
var avatarLines = profile.AvatarAscii.Split('\n').Length; var avatarLines = profile.AvatarAscii.Split('\n').Length;
var avatarHeight = Math.Min(avatarLines + 2, 6); var avatarHeight = Math.Min(avatarLines + 2, 6);
var avatarFrame = new FrameView var avatarFrame = new FrameView
{ {
Title = "Avatar", Title = "Avatar",
X = 1, X = 1,
Y = row, Y = row,
Width = Dim.Fill(2), Width = Dim.Fill(2),
Height = avatarHeight Height = avatarHeight
}; };
avatarFrame.Add(new Label { Text = profile.AvatarAscii, X = 0, Y = 0 }); avatarFrame.Add(new Label { Text = profile.AvatarAscii, X = 0, Y = 0 });
dialog.Add(avatarFrame); dialog.Add(avatarFrame);
// Grow dialog to fit avatar // Grow dialog to fit avatar
dialog.Height = row + avatarHeight + 4; dialog.Height = row + avatarHeight + 4;
} }
// Buttons // Buttons
if (isOwnProfile) if (isOwnProfile)
{ {
var editButton = new Button var editButton = new Button
{ {
Text = "Edit Profile", Text = "Edit Profile",
X = Pos.Center() - 20, X = Pos.Center() - 20,
Y = Pos.AnchorEnd(2) Y = Pos.AnchorEnd(2)
}; };
editButton.Accepting += (s, e) => editButton.Accepting += (s, e) =>
{ {
action = ProfileAction.EditProfile; action = ProfileAction.EditProfile;
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
var statusButton = new Button var statusButton = new Button
{ {
Text = "Set Status", Text = "Set Status",
X = Pos.Center() - 4, X = Pos.Center() - 4,
Y = Pos.AnchorEnd(2) Y = Pos.AnchorEnd(2)
}; };
statusButton.Accepting += (s, e) => statusButton.Accepting += (s, e) =>
{ {
action = ProfileAction.SetStatus; action = ProfileAction.SetStatus;
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
var closeButton = new Button var closeButton = new Button
{ {
Text = "Close", Text = "Close",
IsDefault = true, IsDefault = true,
X = Pos.Center() + 13, X = Pos.Center() + 13,
Y = Pos.AnchorEnd(2) Y = Pos.AnchorEnd(2)
}; };
closeButton.Accepting += (s, e) => closeButton.Accepting += (s, e) =>
{ {
action = ProfileAction.Close; action = ProfileAction.Close;
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
dialog.Add(editButton, statusButton, closeButton); dialog.Add(editButton, statusButton, closeButton);
} }
else else
{ {
var closeButton = new Button var closeButton = new Button
{ {
Text = "Close", Text = "Close",
IsDefault = true, IsDefault = true,
X = Pos.Center(), X = Pos.Center(),
Y = Pos.AnchorEnd(2) Y = Pos.AnchorEnd(2)
}; };
closeButton.Accepting += (s, e) => closeButton.Accepting += (s, e) =>
{ {
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
dialog.Add(closeButton); dialog.Add(closeButton);
} }
app.Run(dialog); app.Run(dialog);
return action; return action;
} }
private static string FormatStatus(UserStatus status) => status switch private static string FormatStatus(UserStatus status) => status switch
{ {
UserStatus.Online => "\u25cf Online", UserStatus.Online => "\u25cf Online",
UserStatus.Away => "\u25cf Away", UserStatus.Away => "\u25cf Away",
UserStatus.DoNotDisturb => "\u25cf Do Not Disturb", UserStatus.DoNotDisturb => "\u25cf Do Not Disturb",
UserStatus.Invisible => "\u25cb Invisible", UserStatus.Invisible => "\u25cb Invisible",
_ => "\u25cf Unknown" _ => "\u25cf Unknown"
}; };
private static Color GetStatusColor(UserStatus status) => status switch private static Color GetStatusColor(UserStatus status) => status switch
{ {
UserStatus.Online => Color.BrightGreen, UserStatus.Online => Color.BrightGreen,
UserStatus.Away => Color.BrightYellow, UserStatus.Away => Color.BrightYellow,
UserStatus.DoNotDisturb => Color.BrightRed, UserStatus.DoNotDisturb => Color.BrightRed,
UserStatus.Invisible => Color.Gray, UserStatus.Invisible => Color.Gray,
_ => Color.White _ => Color.White
}; };
} }
+22 -22
View File
@@ -1,22 +1,22 @@
{ {
"Serilog": { "Serilog": {
"MinimumLevel": { "MinimumLevel": {
"Default": "Debug", "Default": "Debug",
"Override": { "Override": {
"Microsoft": "Warning", "Microsoft": "Warning",
"System": "Warning" "System": "Warning"
} }
}, },
"WriteTo": [ "WriteTo": [
{ {
"Name": "File", "Name": "File",
"Args": { "Args": {
"path": "logs/echohub-.log", "path": "logs/echohub-.log",
"rollingInterval": "Day", "rollingInterval": "Day",
"retainedFileCountLimit": 7, "retainedFileCountLimit": 7,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}" "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
} }
} }
] ]
} }
} }
@@ -1,26 +1,26 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace EchoHub.Core.Constants; namespace EchoHub.Core.Constants;
public static partial class ValidationConstants public static partial class ValidationConstants
{ {
public const string UsernamePattern = @"^[a-zA-Z0-9_-]{3,50}$"; public const string UsernamePattern = @"^[a-zA-Z0-9_-]{3,50}$";
public const string ChannelNamePattern = @"^[a-zA-Z0-9_-]{2,100}$"; public const string ChannelNamePattern = @"^[a-zA-Z0-9_-]{2,100}$";
public const string HexColorPattern = @"^#[0-9a-fA-F]{6}$"; public const string HexColorPattern = @"^#[0-9a-fA-F]{6}$";
public const int MaxPasswordLength = 128; public const int MaxPasswordLength = 128;
public const int MaxDisplayNameLength = 100; public const int MaxDisplayNameLength = 100;
public const int MaxBioLength = 500; public const int MaxBioLength = 500;
public const int MaxStatusMessageLength = 100; public const int MaxStatusMessageLength = 100;
public const int MaxChannelTopicLength = 500; public const int MaxChannelTopicLength = 500;
public const int MaxHistoryCount = 100; public const int MaxHistoryCount = 100;
[GeneratedRegex(UsernamePattern)] [GeneratedRegex(UsernamePattern)]
public static partial Regex UsernameRegex(); public static partial Regex UsernameRegex();
[GeneratedRegex(ChannelNamePattern)] [GeneratedRegex(ChannelNamePattern)]
public static partial Regex ChannelNameRegex(); public static partial Regex ChannelNameRegex();
[GeneratedRegex(HexColorPattern)] [GeneratedRegex(HexColorPattern)]
public static partial Regex HexColorRegex(); public static partial Regex HexColorRegex();
} }
+5 -5
View File
@@ -1,5 +1,5 @@
namespace EchoHub.Core.DTOs; namespace EchoHub.Core.DTOs;
public record ErrorResponse(string Error, string? Detail = null); public record ErrorResponse(string Error, string? Detail = null);
public record PaginatedResponse<T>(List<T> Items, int Total, int Offset, int Limit); public record PaginatedResponse<T>(List<T> Items, int Total, int Offset, int Limit);
+9 -9
View File
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+17 -17
View File
@@ -1,17 +1,17 @@
namespace EchoHub.Core.Models; namespace EchoHub.Core.Models;
public class RefreshToken public class RefreshToken
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public required string TokenHash { get; set; } public required string TokenHash { get; set; }
public Guid UserId { get; set; } public Guid UserId { get; set; }
public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? RevokedAt { get; set; } public DateTimeOffset? RevokedAt { get; set; }
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt; public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
public bool IsRevoked => RevokedAt is not null; public bool IsRevoked => RevokedAt is not null;
public bool IsActive => !IsExpired && !IsRevoked; public bool IsActive => !IsExpired && !IsRevoked;
public User? User { get; set; } public User? User { get; set; }
} }
+146 -146
View File
@@ -1,146 +1,146 @@
using EchoHub.Core.Constants; using EchoHub.Core.Constants;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Server.Auth; using EchoHub.Server.Auth;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers; namespace EchoHub.Server.Controllers;
[ApiController] [ApiController]
[Route("api/auth")] [Route("api/auth")]
[EnableRateLimiting("auth")] [EnableRateLimiting("auth")]
public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : ControllerBase public class AuthController(EchoHubDbContext db, JwtTokenService jwt) : ControllerBase
{ {
[HttpPost("register")] [HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request) public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new ErrorResponse("Username and password are required.")); return BadRequest(new ErrorResponse("Username and password are required."));
if (!ValidationConstants.UsernameRegex().IsMatch(request.Username)) if (!ValidationConstants.UsernameRegex().IsMatch(request.Username))
return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens.")); return BadRequest(new ErrorResponse("Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens."));
if (request.Password.Length < 6) if (request.Password.Length < 6)
return BadRequest(new ErrorResponse("Password must be at least 6 characters.")); return BadRequest(new ErrorResponse("Password must be at least 6 characters."));
if (request.Password.Length > ValidationConstants.MaxPasswordLength) if (request.Password.Length > ValidationConstants.MaxPasswordLength)
return BadRequest(new ErrorResponse($"Password must not exceed {ValidationConstants.MaxPasswordLength} characters.")); return BadRequest(new ErrorResponse($"Password must not exceed {ValidationConstants.MaxPasswordLength} characters."));
var normalizedUsername = request.Username.ToLowerInvariant().Trim(); var normalizedUsername = request.Username.ToLowerInvariant().Trim();
if (await db.Users.AnyAsync(u => u.Username == normalizedUsername)) if (await db.Users.AnyAsync(u => u.Username == normalizedUsername))
return Conflict(new ErrorResponse("Username is already taken.")); return Conflict(new ErrorResponse("Username is already taken."));
var user = new User var user = new User
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Username = normalizedUsername, Username = normalizedUsername,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
DisplayName = request.DisplayName?.Trim(), DisplayName = request.DisplayName?.Trim(),
}; };
db.Users.Add(user); db.Users.Add(user);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user); var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
var refreshToken = JwtTokenService.GenerateRefreshToken(); var refreshToken = JwtTokenService.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken db.RefreshTokens.Add(new RefreshToken
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(refreshToken), TokenHash = JwtTokenService.HashToken(refreshToken),
UserId = user.Id, UserId = user.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
} }
[HttpPost("login")] [HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request) public async Task<IActionResult> Login([FromBody] LoginRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new ErrorResponse("Username and password are required.")); return BadRequest(new ErrorResponse("Username and password are required."));
var normalizedUsername = request.Username.ToLowerInvariant().Trim(); var normalizedUsername = request.Username.ToLowerInvariant().Trim();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
return Unauthorized(new ErrorResponse("Invalid username or password.")); return Unauthorized(new ErrorResponse("Invalid username or password."));
user.LastSeenAt = DateTimeOffset.UtcNow; user.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user); var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
var refreshToken = JwtTokenService.GenerateRefreshToken(); var refreshToken = JwtTokenService.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken db.RefreshTokens.Add(new RefreshToken
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(refreshToken), TokenHash = JwtTokenService.HashToken(refreshToken),
UserId = user.Id, UserId = user.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); return Ok(new LoginResponse(accessToken, refreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
} }
[HttpPost("refresh")] [HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request) public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.RefreshToken)) if (string.IsNullOrWhiteSpace(request.RefreshToken))
return BadRequest(new ErrorResponse("Refresh token is required.")); return BadRequest(new ErrorResponse("Refresh token is required."));
var tokenHash = JwtTokenService.HashToken(request.RefreshToken); var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
var storedToken = await db.RefreshTokens var storedToken = await db.RefreshTokens
.Include(r => r.User) .Include(r => r.User)
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash); .FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
if (storedToken is null || !storedToken.IsActive || storedToken.User is null) if (storedToken is null || !storedToken.IsActive || storedToken.User is null)
return Unauthorized(new ErrorResponse("Invalid or expired refresh token.")); return Unauthorized(new ErrorResponse("Invalid or expired refresh token."));
// Revoke old refresh token (rotation) // Revoke old refresh token (rotation)
storedToken.RevokedAt = DateTimeOffset.UtcNow; storedToken.RevokedAt = DateTimeOffset.UtcNow;
var user = storedToken.User; var user = storedToken.User;
user.LastSeenAt = DateTimeOffset.UtcNow; user.LastSeenAt = DateTimeOffset.UtcNow;
// Issue new token pair // Issue new token pair
var (accessToken, expiresAt) = jwt.GenerateAccessToken(user); var (accessToken, expiresAt) = jwt.GenerateAccessToken(user);
var newRefreshToken = JwtTokenService.GenerateRefreshToken(); var newRefreshToken = JwtTokenService.GenerateRefreshToken();
db.RefreshTokens.Add(new RefreshToken db.RefreshTokens.Add(new RefreshToken
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
TokenHash = JwtTokenService.HashToken(newRefreshToken), TokenHash = JwtTokenService.HashToken(newRefreshToken),
UserId = user.Id, UserId = user.Id,
ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime), ExpiresAt = DateTimeOffset.UtcNow.Add(JwtTokenService.RefreshTokenLifetime),
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor)); return Ok(new LoginResponse(accessToken, newRefreshToken, expiresAt, user.Username, user.DisplayName, user.NicknameColor));
} }
[HttpPost("logout")] [HttpPost("logout")]
public async Task<IActionResult> Logout([FromBody] RefreshRequest request) public async Task<IActionResult> Logout([FromBody] RefreshRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.RefreshToken)) if (string.IsNullOrWhiteSpace(request.RefreshToken))
return BadRequest(new ErrorResponse("Refresh token is required.")); return BadRequest(new ErrorResponse("Refresh token is required."));
var tokenHash = JwtTokenService.HashToken(request.RefreshToken); var tokenHash = JwtTokenService.HashToken(request.RefreshToken);
var storedToken = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash); var storedToken = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash);
if (storedToken is not null && storedToken.IsActive) if (storedToken is not null && storedToken.IsActive)
{ {
storedToken.RevokedAt = DateTimeOffset.UtcNow; storedToken.RevokedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
return Ok(); return Ok();
} }
} }
@@ -1,338 +1,338 @@
using System.Security.Claims; using System.Security.Claims;
using EchoHub.Core.Constants; using EchoHub.Core.Constants;
using EchoHub.Core.Contracts; using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using EchoHub.Server.Hubs; using EchoHub.Server.Hubs;
using EchoHub.Server.Services; using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers; namespace EchoHub.Server.Controllers;
[ApiController] [ApiController]
[Route("api/channels")] [Route("api/channels")]
[Authorize] [Authorize]
[EnableRateLimiting("general")] [EnableRateLimiting("general")]
public class ChannelsController( public class ChannelsController(
EchoHubDbContext db, EchoHubDbContext db,
FileStorageService fileStorage, FileStorageService fileStorage,
ImageToAsciiService asciiService, ImageToAsciiService asciiService,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IHubContext<ChatHub, IEchoHubClient> hubContext) : ControllerBase IHubContext<ChatHub, IEchoHubClient> hubContext) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50) public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
{ {
offset = Math.Max(0, offset); offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100); limit = Math.Clamp(limit, 1, 100);
var total = await db.Channels.CountAsync(); var total = await db.Channels.CountAsync();
var channels = await db.Channels var channels = await db.Channels
.OrderBy(c => c.Name) .OrderBy(c => c.Name)
.Skip(offset) .Skip(offset)
.Take(limit) .Take(limit)
.Select(c => new ChannelDto( .Select(c => new ChannelDto(
c.Id, c.Id,
c.Name, c.Name,
c.Topic, c.Topic,
c.Messages.Count, c.Messages.Count,
c.CreatedAt)) c.CreatedAt))
.ToListAsync(); .ToListAsync();
return Ok(new PaginatedResponse<ChannelDto>(channels, total, offset, limit)); return Ok(new PaginatedResponse<ChannelDto>(channels, total, offset, limit));
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request) public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.Name)) if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new ErrorResponse("Channel name is required.")); return BadRequest(new ErrorResponse("Channel name is required."));
var channelName = request.Name.ToLowerInvariant().Trim(); var channelName = request.Name.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.")); return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
if (await db.Channels.AnyAsync(c => c.Name == channelName)) if (await db.Channels.AnyAsync(c => c.Name == channelName))
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists.")); return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channel = new Channel var channel = new Channel
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = channelName, Name = channelName,
Topic = request.Topic?.Trim(), Topic = request.Topic?.Trim(),
CreatedByUserId = Guid.Parse(userIdClaim), CreatedByUserId = Guid.Parse(userIdClaim),
}; };
db.Channels.Add(channel); db.Channels.Add(channel);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt); var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt);
await hubContext.Clients.All.ChannelUpdated(dto); await hubContext.Clients.All.ChannelUpdated(dto);
return Created($"/api/channels/{channelName}", dto); return Created($"/api/channels/{channelName}", dto);
} }
[HttpPut("{channel}/topic")] [HttpPut("{channel}/topic")]
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request) public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim(); var channelName = channel.ToLowerInvariant().Trim();
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null) if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim)) if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic.")); return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic."));
if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength) if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength)
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters.")); return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
dbChannel.Topic = request.Topic?.Trim(); dbChannel.Topic = request.Topic?.Trim();
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id); var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt); var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt);
await hubContext.Clients.Group(channelName).ChannelUpdated(dto); await hubContext.Clients.Group(channelName).ChannelUpdated(dto);
return Ok(dto); return Ok(dto);
} }
[HttpDelete("{channel}")] [HttpDelete("{channel}")]
public async Task<IActionResult> DeleteChannel(string channel) public async Task<IActionResult> DeleteChannel(string channel)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim(); var channelName = channel.ToLowerInvariant().Trim();
if (channelName == HubConstants.DefaultChannel) if (channelName == HubConstants.DefaultChannel)
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted.")); return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null) if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim)) if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel.")); return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel."));
db.Channels.Remove(dbChannel); db.Channels.Remove(dbChannel);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return NoContent(); return NoContent();
} }
[HttpPost("{channel}/upload")] [HttpPost("{channel}/upload")]
[EnableRateLimiting("upload")] [EnableRateLimiting("upload")]
public async Task<IActionResult> Upload(string channel) public async Task<IActionResult> Upload(string channel)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = User.FindFirstValue("username"); var usernameClaim = User.FindFirstValue("username");
if (userIdClaim is null || usernameClaim is null) if (userIdClaim is null || usernameClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim); var userId = Guid.Parse(userIdClaim);
var channelName = channel.ToLowerInvariant().Trim(); var channelName = channel.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format.")); return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null) if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0) if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
return BadRequest(new ErrorResponse("No file uploaded.")); return BadRequest(new ErrorResponse("No file uploaded."));
var file = Request.Form.Files[0]; var file = Request.Form.Files[0];
if (file.Length > HubConstants.MaxFileSizeBytes) if (file.Length > HubConstants.MaxFileSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
// Detect if file is an image by checking magic bytes // Detect if file is an image by checking magic bytes
using var stream = file.OpenReadStream(); using var stream = file.OpenReadStream();
var isImage = FileValidationHelper.IsValidImage(stream); var isImage = FileValidationHelper.IsValidImage(stream);
var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName); var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName);
var messageType = isImage ? MessageType.Image : MessageType.File; var messageType = isImage ? MessageType.Image : MessageType.File;
string content; string content;
if (isImage) if (isImage)
{ {
using var imageStream = System.IO.File.OpenRead(filePath); using var imageStream = System.IO.File.OpenRead(filePath);
content = asciiService.ConvertToAscii(imageStream); content = asciiService.ConvertToAscii(imageStream);
} }
else else
{ {
content = file.FileName; content = file.FileName;
} }
var attachmentUrl = $"/api/files/{fileId}"; var attachmentUrl = $"/api/files/{fileId}";
var sender = await db.Users.FindAsync(userId); var sender = await db.Users.FindAsync(userId);
var message = new Message var message = new Message
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Content = content, Content = content,
Type = messageType, Type = messageType,
AttachmentUrl = attachmentUrl, AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName, AttachmentFileName = file.FileName,
SentAt = DateTimeOffset.UtcNow, SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id, ChannelId = dbChannel.Id,
SenderUserId = userId, SenderUserId = userId,
SenderUsername = usernameClaim, SenderUsername = usernameClaim,
}; };
db.Messages.Add(message); db.Messages.Add(message);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var messageDto = new MessageDto( var messageDto = new MessageDto(
message.Id, message.Id,
message.Content, message.Content,
message.SenderUsername, message.SenderUsername,
sender?.NicknameColor, sender?.NicknameColor,
channelName, channelName,
messageType, messageType,
attachmentUrl, attachmentUrl,
file.FileName, file.FileName,
message.SentAt); message.SentAt);
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto); await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
return Ok(messageDto); return Ok(messageDto);
} }
[HttpPost("{channel}/send-url")] [HttpPost("{channel}/send-url")]
[EnableRateLimiting("upload")] [EnableRateLimiting("upload")]
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request) public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = User.FindFirstValue("username"); var usernameClaim = User.FindFirstValue("username");
if (userIdClaim is null || usernameClaim is null) if (userIdClaim is null || usernameClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim); var userId = Guid.Parse(userIdClaim);
var channelName = channel.ToLowerInvariant().Trim(); var channelName = channel.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format.")); return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null) if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (string.IsNullOrWhiteSpace(request.Url)) if (string.IsNullOrWhiteSpace(request.Url))
return BadRequest(new ErrorResponse("URL is required.")); return BadRequest(new ErrorResponse("URL is required."));
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri)
|| (uri.Scheme != "http" && uri.Scheme != "https")) || (uri.Scheme != "http" && uri.Scheme != "https"))
return BadRequest(new ErrorResponse("Invalid URL. Only http and https are supported.")); return BadRequest(new ErrorResponse("Invalid URL. Only http and https are supported."));
// Download image from URL // Download image from URL
byte[] imageBytes; byte[] imageBytes;
string fileName; string fileName;
try try
{ {
using var client = httpClientFactory.CreateClient("ImageDownload"); using var client = httpClientFactory.CreateClient("ImageDownload");
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength; var contentLength = response.Content.Headers.ContentLength;
if (contentLength > HubConstants.MaxFileSizeBytes) if (contentLength > HubConstants.MaxFileSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
imageBytes = await response.Content.ReadAsByteArrayAsync(); imageBytes = await response.Content.ReadAsByteArrayAsync();
if (imageBytes.Length > HubConstants.MaxFileSizeBytes) if (imageBytes.Length > HubConstants.MaxFileSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
fileName = Path.GetFileName(uri.LocalPath); fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.')) if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
{ {
var contentType = response.Content.Headers.ContentType?.MediaType ?? ""; var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
var ext = contentType switch var ext = contentType switch
{ {
"image/png" => ".png", "image/png" => ".png",
"image/jpeg" or "image/jpg" => ".jpg", "image/jpeg" or "image/jpg" => ".jpg",
"image/gif" => ".gif", "image/gif" => ".gif",
"image/webp" => ".webp", "image/webp" => ".webp",
_ => ".bin" _ => ".bin"
}; };
fileName = $"download{ext}"; fileName = $"download{ext}";
} }
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
return BadRequest(new ErrorResponse("Download timed out. The URL may be unreachable.")); return BadRequest(new ErrorResponse("Download timed out. The URL may be unreachable."));
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
return BadRequest(new ErrorResponse($"Failed to download from URL: {ex.Message}")); return BadRequest(new ErrorResponse($"Failed to download from URL: {ex.Message}"));
} }
// Validate it's actually an image // Validate it's actually an image
using var memoryStream = new MemoryStream(imageBytes); using var memoryStream = new MemoryStream(imageBytes);
if (!FileValidationHelper.IsValidImage(memoryStream)) if (!FileValidationHelper.IsValidImage(memoryStream))
return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP.")); return BadRequest(new ErrorResponse("The URL does not point to a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
// Save file and convert to ASCII // Save file and convert to ASCII
var (fileId, filePath) = await fileStorage.SaveFileAsync(memoryStream, fileName); var (fileId, filePath) = await fileStorage.SaveFileAsync(memoryStream, fileName);
string content; string content;
using (var imageStream = System.IO.File.OpenRead(filePath)) using (var imageStream = System.IO.File.OpenRead(filePath))
{ {
content = asciiService.ConvertToAscii(imageStream); content = asciiService.ConvertToAscii(imageStream);
} }
var attachmentUrl = $"/api/files/{fileId}"; var attachmentUrl = $"/api/files/{fileId}";
var sender = await db.Users.FindAsync(userId); var sender = await db.Users.FindAsync(userId);
var message = new Message var message = new Message
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Content = content, Content = content,
Type = MessageType.Image, Type = MessageType.Image,
AttachmentUrl = attachmentUrl, AttachmentUrl = attachmentUrl,
AttachmentFileName = fileName, AttachmentFileName = fileName,
SentAt = DateTimeOffset.UtcNow, SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id, ChannelId = dbChannel.Id,
SenderUserId = userId, SenderUserId = userId,
SenderUsername = usernameClaim, SenderUsername = usernameClaim,
}; };
db.Messages.Add(message); db.Messages.Add(message);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var messageDto = new MessageDto( var messageDto = new MessageDto(
message.Id, message.Id,
message.Content, message.Content,
message.SenderUsername, message.SenderUsername,
sender?.NicknameColor, sender?.NicknameColor,
channelName, channelName,
MessageType.Image, MessageType.Image,
attachmentUrl, attachmentUrl,
fileName, fileName,
message.SentAt); message.SentAt);
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto); await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
return Ok(messageDto); return Ok(messageDto);
} }
} }
@@ -1,40 +1,40 @@
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Server.Services; using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
namespace EchoHub.Server.Controllers; namespace EchoHub.Server.Controllers;
[ApiController] [ApiController]
[Route("api/files")] [Route("api/files")]
[Authorize] [Authorize]
[EnableRateLimiting("general")] [EnableRateLimiting("general")]
public class FilesController(FileStorageService fileStorage) : ControllerBase public class FilesController(FileStorageService fileStorage) : ControllerBase
{ {
[HttpGet("{fileId}")] [HttpGet("{fileId}")]
public IActionResult GetFile(string fileId) public IActionResult GetFile(string fileId)
{ {
if (!Guid.TryParse(fileId, out _)) if (!Guid.TryParse(fileId, out _))
return BadRequest(new ErrorResponse("Invalid file identifier.")); return BadRequest(new ErrorResponse("Invalid file identifier."));
var filePath = fileStorage.GetFilePath(fileId); var filePath = fileStorage.GetFilePath(fileId);
if (filePath is null) if (filePath is null)
return NotFound(new ErrorResponse("File not found.")); return NotFound(new ErrorResponse("File not found."));
var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch
{ {
".jpg" or ".jpeg" => "image/jpeg", ".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png", ".png" => "image/png",
".gif" => "image/gif", ".gif" => "image/gif",
".webp" => "image/webp", ".webp" => "image/webp",
".pdf" => "application/pdf", ".pdf" => "application/pdf",
".txt" => "text/plain", ".txt" => "text/plain",
_ => "application/octet-stream" _ => "application/octet-stream"
}; };
var fileName = Path.GetFileName(filePath); var fileName = Path.GetFileName(filePath);
return PhysicalFile(filePath, contentType, fileName); return PhysicalFile(filePath, contentType, fileName);
} }
} }
@@ -1,26 +1,26 @@
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers; namespace EchoHub.Server.Controllers;
[ApiController] [ApiController]
[Route("api/server")] [Route("api/server")]
public class ServerController(EchoHubDbContext db, IConfiguration config) : ControllerBase public class ServerController(EchoHubDbContext db, IConfiguration config) : ControllerBase
{ {
[HttpGet("info")] [HttpGet("info")]
public async Task<IActionResult> GetInfo() public async Task<IActionResult> GetInfo()
{ {
var userCount = await db.Users.CountAsync(); var userCount = await db.Users.CountAsync();
var channelCount = await db.Channels.CountAsync(); var channelCount = await db.Channels.CountAsync();
var status = new ServerStatusDto( var status = new ServerStatusDto(
config["Server:Name"] ?? "EchoHub Server", config["Server:Name"] ?? "EchoHub Server",
config["Server:Description"], config["Server:Description"],
userCount, userCount,
channelCount); channelCount);
return Ok(status); return Ok(status);
} }
} }
+117 -117
View File
@@ -1,117 +1,117 @@
using System.Security.Claims; using System.Security.Claims;
using EchoHub.Core.Constants; using EchoHub.Core.Constants;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using EchoHub.Server.Services; using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers; namespace EchoHub.Server.Controllers;
[ApiController] [ApiController]
[Route("api/users")] [Route("api/users")]
[Authorize] [Authorize]
[EnableRateLimiting("general")] [EnableRateLimiting("general")]
public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) : ControllerBase public class UsersController(EchoHubDbContext db, ImageToAsciiService asciiService) : ControllerBase
{ {
[HttpGet("{username}/profile")] [HttpGet("{username}/profile")]
public async Task<IActionResult> GetProfile(string username) public async Task<IActionResult> GetProfile(string username)
{ {
var normalizedUsername = username.ToLowerInvariant().Trim(); var normalizedUsername = username.ToLowerInvariant().Trim();
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername);
if (user is null) if (user is null)
return NotFound(new ErrorResponse("User not found.")); return NotFound(new ErrorResponse("User not found."));
return Ok(ToProfileDto(user)); return Ok(ToProfileDto(user));
} }
[HttpPut("profile")] [HttpPut("profile")]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request) public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim); var userId = Guid.Parse(userIdClaim);
var user = await db.Users.FindAsync(userId); var user = await db.Users.FindAsync(userId);
if (user is null) if (user is null)
return NotFound(new ErrorResponse("User not found.")); return NotFound(new ErrorResponse("User not found."));
if (request.DisplayName is not null) if (request.DisplayName is not null)
{ {
if (request.DisplayName.Length > ValidationConstants.MaxDisplayNameLength) if (request.DisplayName.Length > ValidationConstants.MaxDisplayNameLength)
return BadRequest(new ErrorResponse($"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters.")); return BadRequest(new ErrorResponse($"Display name must not exceed {ValidationConstants.MaxDisplayNameLength} characters."));
user.DisplayName = request.DisplayName.Trim(); user.DisplayName = request.DisplayName.Trim();
} }
if (request.Bio is not null) if (request.Bio is not null)
{ {
if (request.Bio.Length > ValidationConstants.MaxBioLength) if (request.Bio.Length > ValidationConstants.MaxBioLength)
return BadRequest(new ErrorResponse($"Bio must not exceed {ValidationConstants.MaxBioLength} characters.")); return BadRequest(new ErrorResponse($"Bio must not exceed {ValidationConstants.MaxBioLength} characters."));
user.Bio = request.Bio.Trim(); user.Bio = request.Bio.Trim();
} }
if (request.NicknameColor is not null) if (request.NicknameColor is not null)
{ {
var color = request.NicknameColor.Trim(); var color = request.NicknameColor.Trim();
if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color)) if (color.Length > 0 && !ValidationConstants.HexColorRegex().IsMatch(color))
return BadRequest(new ErrorResponse("Nickname color must be a valid hex color (e.g. #FF5500).")); return BadRequest(new ErrorResponse("Nickname color must be a valid hex color (e.g. #FF5500)."));
user.NicknameColor = color.Length > 0 ? color : null; user.NicknameColor = color.Length > 0 ? color : null;
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(ToProfileDto(user)); return Ok(ToProfileDto(user));
} }
[HttpPost("avatar")] [HttpPost("avatar")]
[EnableRateLimiting("upload")] [EnableRateLimiting("upload")]
public async Task<IActionResult> UploadAvatar() public async Task<IActionResult> UploadAvatar()
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim); var userId = Guid.Parse(userIdClaim);
var user = await db.Users.FindAsync(userId); var user = await db.Users.FindAsync(userId);
if (user is null) if (user is null)
return NotFound(new ErrorResponse("User not found.")); return NotFound(new ErrorResponse("User not found."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0) if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
return BadRequest(new ErrorResponse("No file uploaded.")); return BadRequest(new ErrorResponse("No file uploaded."));
var file = Request.Form.Files[0]; var file = Request.Form.Files[0];
if (file.Length > HubConstants.MaxAvatarSizeBytes) if (file.Length > HubConstants.MaxAvatarSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
using var stream = file.OpenReadStream(); using var stream = file.OpenReadStream();
if (!FileValidationHelper.IsValidImage(stream)) if (!FileValidationHelper.IsValidImage(stream))
return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP.")); return BadRequest(new ErrorResponse("File is not a valid image. Supported formats: JPEG, PNG, GIF, WebP."));
var asciiArt = asciiService.ConvertToAscii(stream); var asciiArt = asciiService.ConvertToAscii(stream);
user.AvatarAscii = asciiArt; user.AvatarAscii = asciiArt;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new AvatarUploadResponse(asciiArt)); return Ok(new AvatarUploadResponse(asciiArt));
} }
private static UserProfileDto ToProfileDto(Core.Models.User user) => new( private static UserProfileDto ToProfileDto(Core.Models.User user) => new(
user.Id, user.Id,
user.Username, user.Username,
user.DisplayName, user.DisplayName,
user.Bio, user.Bio,
user.NicknameColor, user.NicknameColor,
user.AvatarAscii, user.AvatarAscii,
user.Status, user.Status,
user.StatusMessage, user.StatusMessage,
user.CreatedAt, user.CreatedAt,
user.LastSeenAt); user.LastSeenAt);
} }
@@ -1,210 +1,210 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace EchoHub.Server.Data.Migrations namespace EchoHub.Server.Data.Migrations
{ {
[DbContext(typeof(EchoHubDbContext))] [DbContext(typeof(EchoHubDbContext))]
[Migration("20260219023113_InitialCreate")] [Migration("20260219023113_InitialCreate")]
partial class InitialCreate partial class InitialCreate
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.Entity("EchoHub.Core.Models.Channel", b => modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId") b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Topic") b.Property<string>("Topic")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Name") b.HasIndex("Name")
.IsUnique(); .IsUnique();
b.ToTable("Channels"); b.ToTable("Channels");
}); });
modelBuilder.Entity("EchoHub.Core.Models.Message", b => modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("AttachmentFileName") b.Property<string>("AttachmentFileName")
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("AttachmentUrl") b.Property<string>("AttachmentUrl")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("ChannelId") b.Property<Guid>("ChannelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Content") b.Property<string>("Content")
.IsRequired() .IsRequired()
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("SenderUserId") b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("SenderUsername") b.Property<string>("SenderUsername")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long>("SentAt") b.Property<long>("SentAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("Type") b.Property<int>("Type")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ChannelId"); b.HasIndex("ChannelId");
b.HasIndex("SentAt"); b.HasIndex("SentAt");
b.ToTable("Messages"); b.ToTable("Messages");
}); });
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<long>("ExpiresAt") b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<long?>("RevokedAt") b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("TokenHash") b.Property<string>("TokenHash")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("UserId") b.Property<Guid>("UserId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("TokenHash"); b.HasIndex("TokenHash");
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("RefreshTokens"); b.ToTable("RefreshTokens");
}); });
modelBuilder.Entity("EchoHub.Core.Models.User", b => modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("AvatarAscii") b.Property<string>("AvatarAscii")
.HasMaxLength(10000) .HasMaxLength(10000)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Bio") b.Property<string>("Bio")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("DisplayName") b.Property<string>("DisplayName")
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long>("LastSeenAt") b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("NicknameColor") b.Property<string>("NicknameColor")
.HasMaxLength(7) .HasMaxLength(7)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("PasswordHash") b.Property<string>("PasswordHash")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("Status") b.Property<int>("Status")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("StatusMessage") b.Property<string>("StatusMessage")
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Username") b.Property<string>("Username")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Username") b.HasIndex("Username")
.IsUnique(); .IsUnique();
b.ToTable("Users"); b.ToTable("Users");
}); });
modelBuilder.Entity("EchoHub.Core.Models.Message", b => modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{ {
b.HasOne("EchoHub.Core.Models.Channel", "Channel") b.HasOne("EchoHub.Core.Models.Channel", "Channel")
.WithMany("Messages") .WithMany("Messages")
.HasForeignKey("ChannelId") .HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Channel"); b.Navigation("Channel");
}); });
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{ {
b.HasOne("EchoHub.Core.Models.User", "User") b.HasOne("EchoHub.Core.Models.User", "User")
.WithMany() .WithMany()
.HasForeignKey("UserId") .HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("EchoHub.Core.Models.Channel", b => modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{ {
b.Navigation("Messages"); b.Navigation("Messages");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
} }
@@ -1,146 +1,146 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace EchoHub.Server.Data.Migrations namespace EchoHub.Server.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class InitialCreate : Migration public partial class InitialCreate : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Channels", name: "Channels",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "TEXT", nullable: false), Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false), Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
Topic = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true), Topic = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false), CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
CreatedByUserId = table.Column<Guid>(type: "TEXT", nullable: false) CreatedByUserId = table.Column<Guid>(type: "TEXT", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_Channels", x => x.Id); table.PrimaryKey("PK_Channels", x => x.Id);
}); });
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Users", name: "Users",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "TEXT", nullable: false), Id = table.Column<Guid>(type: "TEXT", nullable: false),
Username = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false), Username = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false), PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
DisplayName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true), DisplayName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
Bio = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true), Bio = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
NicknameColor = table.Column<string>(type: "TEXT", maxLength: 7, nullable: true), NicknameColor = table.Column<string>(type: "TEXT", maxLength: 7, nullable: true),
AvatarAscii = table.Column<string>(type: "TEXT", maxLength: 10000, nullable: true), AvatarAscii = table.Column<string>(type: "TEXT", maxLength: 10000, nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: false), Status = table.Column<int>(type: "INTEGER", nullable: false),
StatusMessage = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true), StatusMessage = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false), CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
LastSeenAt = table.Column<long>(type: "INTEGER", nullable: false) LastSeenAt = table.Column<long>(type: "INTEGER", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_Users", x => x.Id); table.PrimaryKey("PK_Users", x => x.Id);
}); });
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Messages", name: "Messages",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "TEXT", nullable: false), Id = table.Column<Guid>(type: "TEXT", nullable: false),
Content = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: false), Content = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false), Type = table.Column<int>(type: "INTEGER", nullable: false),
AttachmentUrl = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true), AttachmentUrl = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
AttachmentFileName = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true), AttachmentFileName = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
SentAt = table.Column<long>(type: "INTEGER", nullable: false), SentAt = table.Column<long>(type: "INTEGER", nullable: false),
ChannelId = table.Column<Guid>(type: "TEXT", nullable: false), ChannelId = table.Column<Guid>(type: "TEXT", nullable: false),
SenderUserId = table.Column<Guid>(type: "TEXT", nullable: false), SenderUserId = table.Column<Guid>(type: "TEXT", nullable: false),
SenderUsername = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false) SenderUsername = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_Messages", x => x.Id); table.PrimaryKey("PK_Messages", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_Messages_Channels_ChannelId", name: "FK_Messages_Channels_ChannelId",
column: x => x.ChannelId, column: x => x.ChannelId,
principalTable: "Channels", principalTable: "Channels",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
}); });
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "RefreshTokens", name: "RefreshTokens",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "TEXT", nullable: false), Id = table.Column<Guid>(type: "TEXT", nullable: false),
TokenHash = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false), TokenHash = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false), UserId = table.Column<Guid>(type: "TEXT", nullable: false),
ExpiresAt = table.Column<long>(type: "INTEGER", nullable: false), ExpiresAt = table.Column<long>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false), CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
RevokedAt = table.Column<long>(type: "INTEGER", nullable: true) RevokedAt = table.Column<long>(type: "INTEGER", nullable: true)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_RefreshTokens", x => x.Id); table.PrimaryKey("PK_RefreshTokens", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_RefreshTokens_Users_UserId", name: "FK_RefreshTokens_Users_UserId",
column: x => x.UserId, column: x => x.UserId,
principalTable: "Users", principalTable: "Users",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
}); });
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Channels_Name", name: "IX_Channels_Name",
table: "Channels", table: "Channels",
column: "Name", column: "Name",
unique: true); unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Messages_ChannelId", name: "IX_Messages_ChannelId",
table: "Messages", table: "Messages",
column: "ChannelId"); column: "ChannelId");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Messages_SentAt", name: "IX_Messages_SentAt",
table: "Messages", table: "Messages",
column: "SentAt"); column: "SentAt");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_TokenHash", name: "IX_RefreshTokens_TokenHash",
table: "RefreshTokens", table: "RefreshTokens",
column: "TokenHash"); column: "TokenHash");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_UserId", name: "IX_RefreshTokens_UserId",
table: "RefreshTokens", table: "RefreshTokens",
column: "UserId"); column: "UserId");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Users_Username", name: "IX_Users_Username",
table: "Users", table: "Users",
column: "Username", column: "Username",
unique: true); unique: true);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "Messages"); name: "Messages");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "RefreshTokens"); name: "RefreshTokens");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "Channels"); name: "Channels");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "Users"); name: "Users");
} }
} }
} }
@@ -1,207 +1,207 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace EchoHub.Server.Data.Migrations namespace EchoHub.Server.Data.Migrations
{ {
[DbContext(typeof(EchoHubDbContext))] [DbContext(typeof(EchoHubDbContext))]
partial class EchoHubDbContextModelSnapshot : ModelSnapshot partial class EchoHubDbContextModelSnapshot : ModelSnapshot
{ {
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.Entity("EchoHub.Core.Models.Channel", b => modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId") b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Topic") b.Property<string>("Topic")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Name") b.HasIndex("Name")
.IsUnique(); .IsUnique();
b.ToTable("Channels"); b.ToTable("Channels");
}); });
modelBuilder.Entity("EchoHub.Core.Models.Message", b => modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("AttachmentFileName") b.Property<string>("AttachmentFileName")
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("AttachmentUrl") b.Property<string>("AttachmentUrl")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("ChannelId") b.Property<Guid>("ChannelId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Content") b.Property<string>("Content")
.IsRequired() .IsRequired()
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("SenderUserId") b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("SenderUsername") b.Property<string>("SenderUsername")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long>("SentAt") b.Property<long>("SentAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("Type") b.Property<int>("Type")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ChannelId"); b.HasIndex("ChannelId");
b.HasIndex("SentAt"); b.HasIndex("SentAt");
b.ToTable("Messages"); b.ToTable("Messages");
}); });
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<long>("ExpiresAt") b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<long?>("RevokedAt") b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("TokenHash") b.Property<string>("TokenHash")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid>("UserId") b.Property<Guid>("UserId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("TokenHash"); b.HasIndex("TokenHash");
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("RefreshTokens"); b.ToTable("RefreshTokens");
}); });
modelBuilder.Entity("EchoHub.Core.Models.User", b => modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("AvatarAscii") b.Property<string>("AvatarAscii")
.HasMaxLength(10000) .HasMaxLength(10000)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Bio") b.Property<string>("Bio")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("DisplayName") b.Property<string>("DisplayName")
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long>("LastSeenAt") b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("NicknameColor") b.Property<string>("NicknameColor")
.HasMaxLength(7) .HasMaxLength(7)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("PasswordHash") b.Property<string>("PasswordHash")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("Status") b.Property<int>("Status")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("StatusMessage") b.Property<string>("StatusMessage")
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Username") b.Property<string>("Username")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Username") b.HasIndex("Username")
.IsUnique(); .IsUnique();
b.ToTable("Users"); b.ToTable("Users");
}); });
modelBuilder.Entity("EchoHub.Core.Models.Message", b => modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{ {
b.HasOne("EchoHub.Core.Models.Channel", "Channel") b.HasOne("EchoHub.Core.Models.Channel", "Channel")
.WithMany("Messages") .WithMany("Messages")
.HasForeignKey("ChannelId") .HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("Channel"); b.Navigation("Channel");
}); });
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{ {
b.HasOne("EchoHub.Core.Models.User", "User") b.HasOne("EchoHub.Core.Models.User", "User")
.WithMany() .WithMany()
.HasForeignKey("UserId") .HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("EchoHub.Core.Models.Channel", b => modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{ {
b.Navigation("Messages"); b.Navigation("Messages");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
} }
+26 -26
View File
@@ -1,26 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" /> <ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" /> <PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+200 -200
View File
@@ -1,200 +1,200 @@
using System.Text; using System.Text;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using EchoHub.Core.Constants; using EchoHub.Core.Constants;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Server.Auth; using EchoHub.Server.Auth;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using EchoHub.Server.Hubs; using EchoHub.Server.Hubs;
using EchoHub.Server.Services; using EchoHub.Server.Services;
using EchoHub.Server.Setup; using EchoHub.Server.Setup;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Serilog; using Serilog;
// ── First-run setup (once) ────────────────────────────────────────────────── // ── First-run setup (once) ──────────────────────────────────────────────────
FirstRunSetup.EnsureAppSettings(); FirstRunSetup.EnsureAppSettings();
// ── Bootstrap logger (replaced by full Serilog once host starts) ──────────── // ── Bootstrap logger (replaced by full Serilog once host starts) ────────────
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.WriteTo.Console() .WriteTo.Console()
.CreateBootstrapLogger(); .CreateBootstrapLogger();
// ── Auto-restart loop ─────────────────────────────────────────────────────── // ── Auto-restart loop ───────────────────────────────────────────────────────
const int maxConsecutiveFailures = 5; const int maxConsecutiveFailures = 5;
var consecutiveFailures = 0; var consecutiveFailures = 0;
while (true) while (true)
{ {
var startTime = DateTimeOffset.UtcNow; var startTime = DateTimeOffset.UtcNow;
try try
{ {
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// ── Serilog ────────────────────────────────────────────────────────── // ── Serilog ──────────────────────────────────────────────────────────
builder.Host.UseSerilog((context, config) => builder.Host.UseSerilog((context, config) =>
config.ReadFrom.Configuration(context.Configuration)); config.ReadFrom.Configuration(context.Configuration));
// ── SQLite + EF Core ───────────────────────────────────────────────── // ── SQLite + EF Core ─────────────────────────────────────────────────
var defaultDbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db"); var defaultDbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db");
var configured = builder.Configuration.GetConnectionString("DefaultConnection"); var configured = builder.Configuration.GetConnectionString("DefaultConnection");
var connectionString = string.IsNullOrWhiteSpace(configured) var connectionString = string.IsNullOrWhiteSpace(configured)
? $"Data Source={defaultDbPath}" ? $"Data Source={defaultDbPath}"
: configured; : configured;
builder.Services.AddDbContext<EchoHubDbContext>(options => builder.Services.AddDbContext<EchoHubDbContext>(options =>
options.UseSqlite(connectionString)); options.UseSqlite(connectionString));
// ── JWT Authentication ─────────────────────────────────────────────── // ── JWT Authentication ───────────────────────────────────────────────
var jwtSecret = builder.Configuration["Jwt:Secret"] var jwtSecret = builder.Configuration["Jwt:Secret"]
?? throw new InvalidOperationException("Jwt:Secret must be configured."); ?? throw new InvalidOperationException("Jwt:Secret must be configured.");
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "EchoHub.Server"; var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "EchoHub.Server";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "EchoHub.Client"; var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "EchoHub.Client";
builder.Services.AddAuthentication(options => builder.Services.AddAuthentication(options =>
{ {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}) })
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
options.TokenValidationParameters = new TokenValidationParameters options.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuer = true, ValidateIssuer = true,
ValidateAudience = true, ValidateAudience = true,
ValidateLifetime = true, ValidateLifetime = true,
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer, ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience, ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
}; };
// Allow SignalR clients to send the JWT via query string // Allow SignalR clients to send the JWT via query string
options.Events = new JwtBearerEvents options.Events = new JwtBearerEvents
{ {
OnMessageReceived = context => OnMessageReceived = context =>
{ {
var accessToken = context.Request.Query["access_token"]; var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path; var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments(HubConstants.ChatHubPath)) if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments(HubConstants.ChatHubPath))
{ {
context.Token = accessToken; context.Token = accessToken;
} }
return Task.CompletedTask; return Task.CompletedTask;
}, },
}; };
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
// ── Controllers + SignalR ──────────────────────────────────────────── // ── Controllers + SignalR ────────────────────────────────────────────
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddSignalR(); builder.Services.AddSignalR();
// ── Services ───────────────────────────────────────────────────────── // ── Services ─────────────────────────────────────────────────────────
builder.Services.AddSingleton<JwtTokenService>(); builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<PresenceTracker>(); builder.Services.AddSingleton<PresenceTracker>();
builder.Services.AddSingleton<ImageToAsciiService>(); builder.Services.AddSingleton<ImageToAsciiService>();
builder.Services.AddSingleton<FileStorageService>(); builder.Services.AddSingleton<FileStorageService>();
builder.Services.AddHostedService<ServerDirectoryService>(); builder.Services.AddHostedService<ServerDirectoryService>();
builder.Services.AddHttpClient("ImageDownload", client => builder.Services.AddHttpClient("ImageDownload", client =>
{ {
client.Timeout = TimeSpan.FromSeconds(15); client.Timeout = TimeSpan.FromSeconds(15);
client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB
}); });
// ── Rate Limiting ──────────────────────────────────────────────────── // ── Rate Limiting ────────────────────────────────────────────────────
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("auth", limiter => options.AddFixedWindowLimiter("auth", limiter =>
{ {
limiter.PermitLimit = 10; limiter.PermitLimit = 10;
limiter.Window = TimeSpan.FromMinutes(1); limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0; limiter.QueueLimit = 0;
}); });
options.AddFixedWindowLimiter("upload", limiter => options.AddFixedWindowLimiter("upload", limiter =>
{ {
limiter.PermitLimit = 5; limiter.PermitLimit = 5;
limiter.Window = TimeSpan.FromMinutes(1); limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0; limiter.QueueLimit = 0;
}); });
options.AddFixedWindowLimiter("general", limiter => options.AddFixedWindowLimiter("general", limiter =>
{ {
limiter.PermitLimit = 100; limiter.PermitLimit = 100;
limiter.Window = TimeSpan.FromMinutes(1); limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0; limiter.QueueLimit = 0;
}); });
}); });
// ── CORS ───────────────────────────────────────────────────────────── // ── CORS ─────────────────────────────────────────────────────────────
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>(); var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>();
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddDefaultPolicy(policy => options.AddDefaultPolicy(policy =>
{ {
policy.AllowAnyHeader() policy.AllowAnyHeader()
.AllowAnyMethod() .AllowAnyMethod()
.AllowCredentials(); .AllowCredentials();
if (allowedOrigins is { Length: > 0 }) if (allowedOrigins is { Length: > 0 })
policy.WithOrigins(allowedOrigins); policy.WithOrigins(allowedOrigins);
else else
policy.SetIsOriginAllowed(_ => true); policy.SetIsOriginAllowed(_ => true);
}); });
}); });
await using var app = builder.Build(); await using var app = builder.Build();
// ── Database initialization ────────────────────────────────────────── // ── Database initialization ──────────────────────────────────────────
await DatabaseSetup.InitializeAsync(app.Services); await DatabaseSetup.InitializeAsync(app.Services);
// ── Middleware ──────────────────────────────────────────────────────── // ── Middleware ────────────────────────────────────────────────────────
app.UseCors(); app.UseCors();
app.UseRateLimiter(); app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// ── Routing ────────────────────────────────────────────────────────── // ── Routing ──────────────────────────────────────────────────────────
app.MapControllers(); app.MapControllers();
app.MapHub<ChatHub>(HubConstants.ChatHubPath); app.MapHub<ChatHub>(HubConstants.ChatHubPath);
await app.RunAsync(); await app.RunAsync();
// Graceful shutdown (Ctrl+C) — exit the loop // Graceful shutdown (Ctrl+C) — exit the loop
Log.Information("Server shut down gracefully"); Log.Information("Server shut down gracefully");
break; break;
} }
catch (Exception ex) catch (Exception ex)
{ {
var uptime = DateTimeOffset.UtcNow - startTime; var uptime = DateTimeOffset.UtcNow - startTime;
// If server ran for over 60 seconds, it's a runtime crash — reset failure count // If server ran for over 60 seconds, it's a runtime crash — reset failure count
if (uptime.TotalSeconds > 60) if (uptime.TotalSeconds > 60)
consecutiveFailures = 0; consecutiveFailures = 0;
consecutiveFailures++; consecutiveFailures++;
Log.Fatal(ex, "Server crashed after {Uptime:g} (failure {Count}/{Max})", Log.Fatal(ex, "Server crashed after {Uptime:g} (failure {Count}/{Max})",
uptime, consecutiveFailures, maxConsecutiveFailures); uptime, consecutiveFailures, maxConsecutiveFailures);
if (consecutiveFailures >= maxConsecutiveFailures) if (consecutiveFailures >= maxConsecutiveFailures)
{ {
Log.Fatal("Too many consecutive failures, server will not restart"); Log.Fatal("Too many consecutive failures, server will not restart");
break; break;
} }
var delaySeconds = Math.Min(Math.Pow(2, consecutiveFailures), 30); var delaySeconds = Math.Min(Math.Pow(2, consecutiveFailures), 30);
Log.Information("Restarting server in {Delay}s...", delaySeconds); Log.Information("Restarting server in {Delay}s...", delaySeconds);
await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); await Task.Delay(TimeSpan.FromSeconds(delaySeconds));
} }
} }
Log.CloseAndFlush(); Log.CloseAndFlush();
@@ -1,23 +1,23 @@
{ {
"$schema": "https://json.schemastore.org/launchsettings.json", "$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": { "profiles": {
"http": { "http": {
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"applicationUrl": "http://localhost:5000", "applicationUrl": "http://localhost:5000",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
}, },
"https": { "https": {
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"applicationUrl": "https://localhost:7171;http://localhost:5189", "applicationUrl": "https://localhost:7171;http://localhost:5189",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
} }
} }
} }
@@ -1,68 +1,68 @@
namespace EchoHub.Server.Services; namespace EchoHub.Server.Services;
public static class FileValidationHelper public static class FileValidationHelper
{ {
private static readonly byte[] JpegMagic = [0xFF, 0xD8, 0xFF]; private static readonly byte[] JpegMagic = [0xFF, 0xD8, 0xFF];
private static readonly byte[] PngMagic = [0x89, 0x50, 0x4E, 0x47]; private static readonly byte[] PngMagic = [0x89, 0x50, 0x4E, 0x47];
private static readonly byte[] GifMagic = [0x47, 0x49, 0x46]; private static readonly byte[] GifMagic = [0x47, 0x49, 0x46];
private static readonly byte[] WebpRiff = [0x52, 0x49, 0x46, 0x46]; // "RIFF" private static readonly byte[] WebpRiff = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
private static readonly byte[] WebpTag = [0x57, 0x45, 0x42, 0x50]; // "WEBP" private static readonly byte[] WebpTag = [0x57, 0x45, 0x42, 0x50]; // "WEBP"
/// <summary> /// <summary>
/// Validates that a stream contains a recognized image format by checking magic bytes. /// Validates that a stream contains a recognized image format by checking magic bytes.
/// The stream position is reset to the beginning after validation. /// The stream position is reset to the beginning after validation.
/// </summary> /// </summary>
public static bool IsValidImage(Stream stream) public static bool IsValidImage(Stream stream)
{ {
if (!stream.CanSeek) if (!stream.CanSeek)
return false; return false;
var originalPosition = stream.Position; var originalPosition = stream.Position;
try try
{ {
var header = new byte[12]; var header = new byte[12];
var bytesRead = stream.Read(header, 0, header.Length); var bytesRead = stream.Read(header, 0, header.Length);
if (bytesRead < 3) if (bytesRead < 3)
return false; return false;
// JPEG: FF D8 FF // JPEG: FF D8 FF
if (StartsWith(header, bytesRead, JpegMagic)) if (StartsWith(header, bytesRead, JpegMagic))
return true; return true;
// PNG: 89 50 4E 47 // PNG: 89 50 4E 47
if (bytesRead >= 4 && StartsWith(header, bytesRead, PngMagic)) if (bytesRead >= 4 && StartsWith(header, bytesRead, PngMagic))
return true; return true;
// GIF: 47 49 46 (GIF87a or GIF89a) // GIF: 47 49 46 (GIF87a or GIF89a)
if (StartsWith(header, bytesRead, GifMagic)) if (StartsWith(header, bytesRead, GifMagic))
return true; return true;
// WebP: RIFF....WEBP // WebP: RIFF....WEBP
if (bytesRead >= 12 && StartsWith(header, bytesRead, WebpRiff) if (bytesRead >= 12 && StartsWith(header, bytesRead, WebpRiff)
&& header[8] == WebpTag[0] && header[9] == WebpTag[1] && header[8] == WebpTag[0] && header[9] == WebpTag[1]
&& header[10] == WebpTag[2] && header[11] == WebpTag[3]) && header[10] == WebpTag[2] && header[11] == WebpTag[3])
return true; return true;
return false; return false;
} }
finally finally
{ {
stream.Position = originalPosition; stream.Position = originalPosition;
} }
} }
private static bool StartsWith(byte[] buffer, int length, byte[] magic) private static bool StartsWith(byte[] buffer, int length, byte[] magic)
{ {
if (length < magic.Length) if (length < magic.Length)
return false; return false;
for (int i = 0; i < magic.Length; i++) for (int i = 0; i < magic.Length; i++)
{ {
if (buffer[i] != magic[i]) if (buffer[i] != magic[i])
return false; return false;
} }
return true; return true;
} }
} }
@@ -1,137 +1,137 @@
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
namespace EchoHub.Server.Services; namespace EchoHub.Server.Services;
public sealed class ServerDirectoryService( public sealed class ServerDirectoryService(
IConfiguration configuration, IConfiguration configuration,
PresenceTracker presenceTracker, PresenceTracker presenceTracker,
ILogger<ServerDirectoryService> logger) : BackgroundService ILogger<ServerDirectoryService> logger) : BackgroundService
{ {
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers"; private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30); private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
private HubConnection? _connection; private HubConnection? _connection;
private int _lastReportedUserCount = -1; private int _lastReportedUserCount = -1;
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
// Yield to let the host finish starting before we log or connect // Yield to let the host finish starting before we log or connect
await Task.Yield(); await Task.Yield();
var isPublic = configuration.GetValue<bool>("Server:PublicServer"); var isPublic = configuration.GetValue<bool>("Server:PublicServer");
if (!isPublic) if (!isPublic)
{ {
logger.LogInformation("PublicServer is disabled — not registering with directory"); logger.LogInformation("PublicServer is disabled — not registering with directory");
return; return;
} }
var host = configuration["Server:PublicHost"]; var host = configuration["Server:PublicHost"];
if (string.IsNullOrWhiteSpace(host)) if (string.IsNullOrWhiteSpace(host))
{ {
logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration"); logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration");
return; return;
} }
var serverName = configuration["Server:Name"] ?? "EchoHub Server"; var serverName = configuration["Server:Name"] ?? "EchoHub Server";
var description = configuration["Server:Description"]; var description = configuration["Server:Description"];
logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host); logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host);
_connection = new HubConnectionBuilder() _connection = new HubConnectionBuilder()
.WithUrl(DirectoryHubUrl) .WithUrl(DirectoryHubUrl)
.WithAutomaticReconnect() .WithAutomaticReconnect()
.Build(); .Build();
_connection.Reconnected += async _ => _connection.Reconnected += async _ =>
{ {
logger.LogInformation("Reconnected to directory — re-registering server"); logger.LogInformation("Reconnected to directory — re-registering server");
await RegisterAsync(serverName, description, host); await RegisterAsync(serverName, description, host);
}; };
_connection.Closed += ex => _connection.Closed += ex =>
{ {
if (ex is not null) if (ex is not null)
logger.LogWarning(ex, "Directory connection closed with error"); logger.LogWarning(ex, "Directory connection closed with error");
return Task.CompletedTask; return Task.CompletedTask;
}; };
// Initial connection with retry // Initial connection with retry
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
try try
{ {
await _connection.StartAsync(stoppingToken); await _connection.StartAsync(stoppingToken);
logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl); logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
break; break;
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to connect to directory — retrying in 30s"); logger.LogWarning(ex, "Failed to connect to directory — retrying in 30s");
await Task.Delay(UpdateInterval, stoppingToken); await Task.Delay(UpdateInterval, stoppingToken);
} }
} }
if (stoppingToken.IsCancellationRequested) if (stoppingToken.IsCancellationRequested)
return; return;
// Register on first connect // Register on first connect
await RegisterAsync(serverName, description, host); await RegisterAsync(serverName, description, host);
// Poll user count and send updates // Poll user count and send updates
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
await Task.Delay(UpdateInterval, stoppingToken); await Task.Delay(UpdateInterval, stoppingToken);
if (_connection.State != HubConnectionState.Connected) if (_connection.State != HubConnectionState.Connected)
continue; continue;
var currentCount = presenceTracker.GetOnlineUserCount(); var currentCount = presenceTracker.GetOnlineUserCount();
if (currentCount == _lastReportedUserCount) if (currentCount == _lastReportedUserCount)
continue; continue;
try try
{ {
await _connection.InvokeAsync("UpdateUserCount", currentCount, stoppingToken); await _connection.InvokeAsync("UpdateUserCount", currentCount, stoppingToken);
_lastReportedUserCount = currentCount; _lastReportedUserCount = currentCount;
logger.LogDebug("Updated directory user count to {Count}", currentCount); logger.LogDebug("Updated directory user count to {Count}", currentCount);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to update user count on directory"); logger.LogWarning(ex, "Failed to update user count on directory");
} }
} }
} }
private async Task RegisterAsync(string name, string? description, string host) private async Task RegisterAsync(string name, string? description, string host)
{ {
if (_connection?.State != HubConnectionState.Connected) if (_connection?.State != HubConnectionState.Connected)
return; return;
try try
{ {
var userCount = presenceTracker.GetOnlineUserCount(); var userCount = presenceTracker.GetOnlineUserCount();
var dto = new RegisterServerDto(name, description, host, userCount); var dto = new RegisterServerDto(name, description, host, userCount);
await _connection.InvokeAsync("RegisterServer", dto); await _connection.InvokeAsync("RegisterServer", dto);
_lastReportedUserCount = userCount; _lastReportedUserCount = userCount;
logger.LogInformation("Registered with directory as {Name} at {Host}", name, host); logger.LogInformation("Registered with directory as {Name} at {Host}", name, host);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, "Failed to register with directory"); logger.LogWarning(ex, "Failed to register with directory");
} }
} }
public override async Task StopAsync(CancellationToken cancellationToken) public override async Task StopAsync(CancellationToken cancellationToken)
{ {
if (_connection is not null) if (_connection is not null)
{ {
await _connection.DisposeAsync(); await _connection.DisposeAsync();
_connection = null; _connection = null;
} }
await base.StopAsync(cancellationToken); await base.StopAsync(cancellationToken);
} }
} }
internal record RegisterServerDto(string Name, string? Description, string Host, int UserCount); internal record RegisterServerDto(string Name, string? Description, string Host, int UserCount);
+90 -90
View File
@@ -1,90 +1,90 @@
using EchoHub.Core.Constants; using EchoHub.Core.Constants;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Setup; namespace EchoHub.Server.Setup;
public static class DatabaseSetup public static class DatabaseSetup
{ {
public static async Task InitializeAsync(IServiceProvider services) public static async Task InitializeAsync(IServiceProvider services)
{ {
using var scope = services.CreateScope(); using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>() var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("EchoHub.Server.Setup.DatabaseSetup"); .CreateLogger("EchoHub.Server.Setup.DatabaseSetup");
await MigrateAsync(db, logger); await MigrateAsync(db, logger);
await SeedDefaultChannelAsync(db, logger); await SeedDefaultChannelAsync(db, logger);
} }
private static async Task MigrateAsync(EchoHubDbContext db, ILogger logger) private static async Task MigrateAsync(EchoHubDbContext db, ILogger logger)
{ {
try try
{ {
if (await db.Database.CanConnectAsync()) if (await db.Database.CanConnectAsync())
await HandleLegacyDatabaseAsync(db, logger); await HandleLegacyDatabaseAsync(db, logger);
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
logger.LogInformation("Database migrated successfully."); logger.LogInformation("Database migrated successfully.");
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Database migration failed."); logger.LogError(ex, "Database migration failed.");
throw; throw;
} }
} }
private static async Task HandleLegacyDatabaseAsync(EchoHubDbContext db, ILogger logger) private static async Task HandleLegacyDatabaseAsync(EchoHubDbContext db, ILogger logger)
{ {
var conn = db.Database.GetDbConnection(); var conn = db.Database.GetDbConnection();
await conn.OpenAsync(); await conn.OpenAsync();
using var cmd = conn.CreateCommand(); using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'"; cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'";
var hasMigrationTable = Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0; var hasMigrationTable = Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0;
if (!hasMigrationTable) if (!hasMigrationTable)
{ {
cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Users'"; cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Users'";
var hasLegacyTables = Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0; var hasLegacyTables = Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0;
if (hasLegacyTables) if (hasLegacyTables)
{ {
var dbPath = conn.DataSource; var dbPath = conn.DataSource;
await conn.CloseAsync(); await conn.CloseAsync();
if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath)) if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath))
{ {
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var backupPath = $"{dbPath}.legacy_{timestamp}"; var backupPath = $"{dbPath}.legacy_{timestamp}";
File.Copy(dbPath, backupPath, overwrite: false); File.Copy(dbPath, backupPath, overwrite: false);
logger.LogWarning("Legacy database backed up to '{BackupPath}'.", backupPath); logger.LogWarning("Legacy database backed up to '{BackupPath}'.", backupPath);
} }
await db.Database.EnsureDeletedAsync(); await db.Database.EnsureDeletedAsync();
logger.LogWarning("Legacy database removed. A new database will be created with migration support."); logger.LogWarning("Legacy database removed. A new database will be created with migration support.");
return; return;
} }
} }
await conn.CloseAsync(); await conn.CloseAsync();
} }
private static async Task SeedDefaultChannelAsync(EchoHubDbContext db, ILogger logger) private static async Task SeedDefaultChannelAsync(EchoHubDbContext db, ILogger logger)
{ {
if (await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel)) if (await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
return; return;
db.Channels.Add(new Channel db.Channels.Add(new Channel
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = HubConstants.DefaultChannel, Name = HubConstants.DefaultChannel,
Topic = "General discussion", Topic = "General discussion",
CreatedByUserId = Guid.Empty, CreatedByUserId = Guid.Empty,
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.LogInformation("Default channel '{Channel}' created.", HubConstants.DefaultChannel); logger.LogInformation("Default channel '{Channel}' created.", HubConstants.DefaultChannel);
} }
} }
+48 -48
View File
@@ -1,48 +1,48 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
namespace EchoHub.Server.Setup; namespace EchoHub.Server.Setup;
public static class FirstRunSetup public static class FirstRunSetup
{ {
public static void EnsureAppSettings() public static void EnsureAppSettings()
{ {
var contentRoot = Directory.GetCurrentDirectory(); var contentRoot = Directory.GetCurrentDirectory();
var settingsPath = Path.Combine(contentRoot, "appsettings.json"); var settingsPath = Path.Combine(contentRoot, "appsettings.json");
var examplePath = Path.Combine(contentRoot, "appsettings.example.json"); var examplePath = Path.Combine(contentRoot, "appsettings.example.json");
if (!File.Exists(settingsPath) && File.Exists(examplePath)) if (!File.Exists(settingsPath) && File.Exists(examplePath))
{ {
File.Copy(examplePath, settingsPath); File.Copy(examplePath, settingsPath);
Console.WriteLine("Created appsettings.json from example config."); Console.WriteLine("Created appsettings.json from example config.");
} }
if (!File.Exists(settingsPath)) if (!File.Exists(settingsPath))
return; return;
EnsureJwtSecret(settingsPath); EnsureJwtSecret(settingsPath);
} }
private static void EnsureJwtSecret(string settingsPath) private static void EnsureJwtSecret(string settingsPath)
{ {
var json = File.ReadAllText(settingsPath); var json = File.ReadAllText(settingsPath);
var root = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip }); var root = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip });
if (root is null) if (root is null)
return; return;
var currentSecret = root["Jwt"]?["Secret"]?.GetValue<string>(); var currentSecret = root["Jwt"]?["Secret"]?.GetValue<string>();
if (!string.IsNullOrEmpty(currentSecret) && !currentSecret.StartsWith("CHANGE_ME")) if (!string.IsNullOrEmpty(currentSecret) && !currentSecret.StartsWith("CHANGE_ME"))
return; return;
var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48)); var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
root["Jwt"] ??= new JsonObject(); root["Jwt"] ??= new JsonObject();
root["Jwt"]!["Secret"] = secret; root["Jwt"]!["Secret"] = secret;
var writeOptions = new JsonSerializerOptions { WriteIndented = true }; var writeOptions = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(settingsPath, root.ToJsonString(writeOptions)); File.WriteAllText(settingsPath, root.ToJsonString(writeOptions));
Console.WriteLine("Generated new JWT secret in appsettings.json."); Console.WriteLine("Generated new JWT secret in appsettings.json.");
} }
} }
@@ -1,12 +0,0 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"System": "Warning"
}
}
}
}
+25 -25
View File
@@ -1,25 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" /> <PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*"> <PackageReference Include="xunit.runner.visualstudio" Version="2.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" /> <ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" /> <ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+56 -56
View File
@@ -1,56 +1,56 @@
using EchoHub.Server.Services; using EchoHub.Server.Services;
using Xunit; using Xunit;
namespace EchoHub.Tests; namespace EchoHub.Tests;
public class FileValidationHelperTests public class FileValidationHelperTests
{ {
[Fact] [Fact]
public void IsValidImage_JpegMagicBytes_ReturnsTrue() public void IsValidImage_JpegMagicBytes_ReturnsTrue()
{ {
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]; byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
using var stream = new MemoryStream(jpeg); using var stream = new MemoryStream(jpeg);
Assert.True(FileValidationHelper.IsValidImage(stream)); Assert.True(FileValidationHelper.IsValidImage(stream));
} }
[Fact] [Fact]
public void IsValidImage_PngMagicBytes_ReturnsTrue() public void IsValidImage_PngMagicBytes_ReturnsTrue()
{ {
byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
using var stream = new MemoryStream(png); using var stream = new MemoryStream(png);
Assert.True(FileValidationHelper.IsValidImage(stream)); Assert.True(FileValidationHelper.IsValidImage(stream));
} }
[Fact] [Fact]
public void IsValidImage_GifMagicBytes_ReturnsTrue() public void IsValidImage_GifMagicBytes_ReturnsTrue()
{ {
byte[] gif = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; byte[] gif = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
using var stream = new MemoryStream(gif); using var stream = new MemoryStream(gif);
Assert.True(FileValidationHelper.IsValidImage(stream)); Assert.True(FileValidationHelper.IsValidImage(stream));
} }
[Fact] [Fact]
public void IsValidImage_WebpMagicBytes_ReturnsTrue() public void IsValidImage_WebpMagicBytes_ReturnsTrue()
{ {
byte[] webp = [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50]; byte[] webp = [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50];
using var stream = new MemoryStream(webp); using var stream = new MemoryStream(webp);
Assert.True(FileValidationHelper.IsValidImage(stream)); Assert.True(FileValidationHelper.IsValidImage(stream));
} }
[Fact] [Fact]
public void IsValidImage_RandomBytes_ReturnsFalse() public void IsValidImage_RandomBytes_ReturnsFalse()
{ {
byte[] random = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05]; byte[] random = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05];
using var stream = new MemoryStream(random); using var stream = new MemoryStream(random);
Assert.False(FileValidationHelper.IsValidImage(stream)); Assert.False(FileValidationHelper.IsValidImage(stream));
} }
[Fact] [Fact]
public void IsValidImage_ResetsStreamPosition() public void IsValidImage_ResetsStreamPosition()
{ {
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]; byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
using var stream = new MemoryStream(jpeg); using var stream = new MemoryStream(jpeg);
FileValidationHelper.IsValidImage(stream); FileValidationHelper.IsValidImage(stream);
Assert.Equal(0, stream.Position); Assert.Equal(0, stream.Position);
} }
} }
+68 -68
View File
@@ -1,68 +1,68 @@
using EchoHub.Server.Services; using EchoHub.Server.Services;
using Xunit; using Xunit;
namespace EchoHub.Tests; namespace EchoHub.Tests;
public class PresenceTrackerTests public class PresenceTrackerTests
{ {
[Fact] [Fact]
public void UserConnected_IsOnline_ReturnsTrue() public void UserConnected_IsOnline_ReturnsTrue()
{ {
var tracker = new PresenceTracker(); var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice"); tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
Assert.True(tracker.IsOnline("alice")); Assert.True(tracker.IsOnline("alice"));
} }
[Fact] [Fact]
public void UserDisconnected_LastConnection_IsOnlineReturnsFalse() public void UserDisconnected_LastConnection_IsOnlineReturnsFalse()
{ {
var tracker = new PresenceTracker(); var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice"); tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
tracker.UserDisconnected("conn1"); tracker.UserDisconnected("conn1");
Assert.False(tracker.IsOnline("alice")); Assert.False(tracker.IsOnline("alice"));
} }
[Fact] [Fact]
public void MultipleConnections_DisconnectOne_StillOnline() public void MultipleConnections_DisconnectOne_StillOnline()
{ {
var tracker = new PresenceTracker(); var tracker = new PresenceTracker();
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
tracker.UserConnected("conn1", userId, "alice"); tracker.UserConnected("conn1", userId, "alice");
tracker.UserConnected("conn2", userId, "alice"); tracker.UserConnected("conn2", userId, "alice");
tracker.UserDisconnected("conn1"); tracker.UserDisconnected("conn1");
Assert.True(tracker.IsOnline("alice")); Assert.True(tracker.IsOnline("alice"));
} }
[Fact] [Fact]
public void JoinChannel_GetOnlineUsersInChannel_ReturnsUser() public void JoinChannel_GetOnlineUsersInChannel_ReturnsUser()
{ {
var tracker = new PresenceTracker(); var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice"); tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
tracker.JoinChannel("alice", "general"); tracker.JoinChannel("alice", "general");
var users = tracker.GetOnlineUsersInChannel("general"); var users = tracker.GetOnlineUsersInChannel("general");
Assert.Contains("alice", users); Assert.Contains("alice", users);
} }
[Fact] [Fact]
public void LeaveChannel_UserNoLongerInChannel() public void LeaveChannel_UserNoLongerInChannel()
{ {
var tracker = new PresenceTracker(); var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice"); tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
tracker.JoinChannel("alice", "general"); tracker.JoinChannel("alice", "general");
tracker.LeaveChannel("alice", "general"); tracker.LeaveChannel("alice", "general");
var users = tracker.GetOnlineUsersInChannel("general"); var users = tracker.GetOnlineUsersInChannel("general");
Assert.DoesNotContain("alice", users); Assert.DoesNotContain("alice", users);
} }
[Fact] [Fact]
public void GetChannelsForUser_ReturnsJoinedChannels() public void GetChannelsForUser_ReturnsJoinedChannels()
{ {
var tracker = new PresenceTracker(); var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice"); tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
tracker.JoinChannel("alice", "general"); tracker.JoinChannel("alice", "general");
tracker.JoinChannel("alice", "random"); tracker.JoinChannel("alice", "random");
var channels = tracker.GetChannelsForUser("alice"); var channels = tracker.GetChannelsForUser("alice");
Assert.Contains("general", channels); Assert.Contains("general", channels);
Assert.Contains("random", channels); Assert.Contains("random", channels);
} }
} }
+46 -46
View File
@@ -1,46 +1,46 @@
using EchoHub.Core.Constants; using EchoHub.Core.Constants;
using Xunit; using Xunit;
namespace EchoHub.Tests; namespace EchoHub.Tests;
public class ValidationConstantsTests public class ValidationConstantsTests
{ {
[Theory] [Theory]
[InlineData("alice", true)] [InlineData("alice", true)]
[InlineData("Bob_123", true)] [InlineData("Bob_123", true)]
[InlineData("user-name", true)] [InlineData("user-name", true)]
[InlineData("abc", true)] [InlineData("abc", true)]
[InlineData("ab", false)] [InlineData("ab", false)]
[InlineData("", false)] [InlineData("", false)]
[InlineData("has space", false)] [InlineData("has space", false)]
[InlineData("has@symbol", false)] [InlineData("has@symbol", false)]
public void UsernameRegex_ValidatesCorrectly(string input, bool expected) public void UsernameRegex_ValidatesCorrectly(string input, bool expected)
{ {
var result = ValidationConstants.UsernameRegex().IsMatch(input); var result = ValidationConstants.UsernameRegex().IsMatch(input);
Assert.Equal(expected, result); Assert.Equal(expected, result);
} }
[Theory] [Theory]
[InlineData("general", true)] [InlineData("general", true)]
[InlineData("my-channel_01", true)] [InlineData("my-channel_01", true)]
[InlineData("ab", true)] [InlineData("ab", true)]
[InlineData("a", false)] [InlineData("a", false)]
[InlineData("has space", false)] [InlineData("has space", false)]
public void ChannelNameRegex_ValidatesCorrectly(string input, bool expected) public void ChannelNameRegex_ValidatesCorrectly(string input, bool expected)
{ {
var result = ValidationConstants.ChannelNameRegex().IsMatch(input); var result = ValidationConstants.ChannelNameRegex().IsMatch(input);
Assert.Equal(expected, result); Assert.Equal(expected, result);
} }
[Theory] [Theory]
[InlineData("#FF0000", true)] [InlineData("#FF0000", true)]
[InlineData("#aabbcc", true)] [InlineData("#aabbcc", true)]
[InlineData("FF0000", false)] [InlineData("FF0000", false)]
[InlineData("#FFF", false)] [InlineData("#FFF", false)]
[InlineData("#GGGGGG", false)] [InlineData("#GGGGGG", false)]
public void HexColorRegex_ValidatesCorrectly(string input, bool expected) public void HexColorRegex_ValidatesCorrectly(string input, bool expected)
{ {
var result = ValidationConstants.HexColorRegex().IsMatch(input); var result = ValidationConstants.HexColorRegex().IsMatch(input);
Assert.Equal(expected, result); Assert.Equal(expected, result);
} }
} }
+6 -6
View File
@@ -1,6 +1,6 @@
<Solution> <Solution>
<Project Path="EchoHub.Client/EchoHub.Client.csproj" /> <Project Path="EchoHub.Client/EchoHub.Client.csproj" />
<Project Path="EchoHub.Core/EchoHub.Core.csproj" /> <Project Path="EchoHub.Core/EchoHub.Core.csproj" />
<Project Path="EchoHub.Server/EchoHub.Server.csproj" /> <Project Path="EchoHub.Server/EchoHub.Server.csproj" />
<Project Path="EchoHub.Tests/EchoHub.Tests.csproj" /> <Project Path="EchoHub.Tests/EchoHub.Tests.csproj" />
</Solution> </Solution>