mirror of
https://github.com/Stone-Red-Code/EchoHub.git
synced 2026-09-04 09:06:07 +02:00
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:
+12
-12
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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: "It’s hard to..., because ..."
|
placeholder: "It’s 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 you’ve thought about.
|
description: Any other approaches you’ve 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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 what’s best for the community and the project
|
- Focus on what’s 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 else’s private information (doxxing)
|
- Publishing someone else’s 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 GitHub’s built-in reporting tools where appropriate, and/or
|
- Use GitHub’s 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
@@ -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 you’re proposing a larger change, open an issue first so we can align on approach.
|
If you’re 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 it’s practical
|
- Add/adjust tests for bug fixes when it’s 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
@@ -1,24 +1,24 @@
|
|||||||
# Security Policy
|
# Security Policy
|
||||||
|
|
||||||
## Reporting a vulnerability
|
## Reporting a vulnerability
|
||||||
|
|
||||||
If you believe you’ve 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 you’ve 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 GitHub’s private vulnerability reporting ("Report a vulnerability"):
|
Preferred: use GitHub’s 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
|
||||||
|
|
||||||
I’ll acknowledge receipt, investigate, and work on a fix. Please avoid publicly disclosing details until a fix is available.
|
I’ll acknowledge receipt, investigate, and work on a fix. Please avoid publicly disclosing details until a fix is available.
|
||||||
|
|||||||
+17
-17
@@ -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
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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'>·</span><a href='https://github.com/HueByte/EchoHub'>GitHub</a><span class='footer-sep'>·</span><a href='https://echohub.voidcube.cloud'>Website</a><span class='footer-sep'>·</span><span class='footer-credit'>Built with <a href='https://dotnet.github.io/docfx'>DocFX</a></span></div></div>",
|
"_appFooter": "<div class='footer-custom'><div class='footer-inner'><span class='footer-brand'>EchoHub</span><span class='footer-sep'>·</span><a href='https://github.com/HueByte/EchoHub'>GitHub</a><span class='footer-sep'>·</span><a href='https://echohub.voidcube.cloud'>Website</a><span class='footer-sep'>·</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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+210
-210
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user