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