Compare commits

..

75 Commits
0.8.2 ... 0.9.4

Author SHA1 Message Date
Leendert de Borst
a759091755 Update AppInfo.cs (#479) 2024-12-16 16:55:58 +01:00
dependabot[bot]
8dc99c09a8 Bump Swashbuckle.AspNetCore from 7.1.0 to 7.2.0
Bumps [Swashbuckle.AspNetCore](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) from 7.1.0 to 7.2.0.
- [Release notes](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/releases)
- [Commits](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/compare/v7.1.0...v7.2.0)

---
updated-dependencies:
- dependency-name: Swashbuckle.AspNetCore
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-16 12:19:17 +01:00
dependabot[bot]
b9ec4baf66 Bump NUglify from 1.21.10 to 1.21.11
Bumps [NUglify](https://github.com/trullock/NUglify) from 1.21.10 to 1.21.11.
- [Release notes](https://github.com/trullock/NUglify/releases)
- [Changelog](https://github.com/trullock/NUglify/blob/master/changelog.md)
- [Commits](https://github.com/trullock/NUglify/compare/v1.21.10...v1.21.11)

---
updated-dependencies:
- dependency-name: NUglify
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-16 12:19:10 +01:00
Leendert de Borst
71ed62cdcb Merge pull request #478 from lanedirt/469-webassembly-required-error-not-visible-in-client-app
Add E2E test for browser with WASM disabled
2024-12-16 12:19:00 +01:00
dependabot[bot]
2bbad8c75c Bump NUnit from 4.2.2 to 4.3.0
Bumps [NUnit](https://github.com/nunit/nunit) from 4.2.2 to 4.3.0.
- [Release notes](https://github.com/nunit/nunit/releases)
- [Changelog](https://github.com/nunit/nunit/blob/main/CHANGES.md)
- [Commits](https://github.com/nunit/nunit/compare/4.2.2...4.3.0)

---
updated-dependencies:
- dependency-name: NUnit
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-16 11:24:59 +01:00
dependabot[bot]
f02b841eea Bump Serilog and Serilog.Settings.Configuration
Bumps [Serilog](https://github.com/serilog/serilog) and [Serilog.Settings.Configuration](https://github.com/serilog/serilog-settings-configuration). These dependencies needed to be updated together.

Updates `Serilog` from 4.2.0 to 4.2.0
- [Release notes](https://github.com/serilog/serilog/releases)
- [Commits](https://github.com/serilog/serilog/compare/v4.2.0...v4.2.0)

Updates `Serilog.Settings.Configuration` from 8.0.4 to 9.0.0
- [Release notes](https://github.com/serilog/serilog-settings-configuration/releases)
- [Changelog](https://github.com/serilog/serilog-settings-configuration/blob/dev/CHANGES.md)
- [Commits](https://github.com/serilog/serilog-settings-configuration/compare/v8.0.4...v9.0.0)

---
updated-dependencies:
- dependency-name: Serilog
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Serilog.Settings.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-16 11:24:36 +01:00
dependabot[bot]
f6fc5af8ac Bump MailKit from 4.8.0 to 4.9.0
Bumps [MailKit](https://github.com/jstedfast/MailKit) from 4.8.0 to 4.9.0.
- [Changelog](https://github.com/jstedfast/MailKit/blob/master/ReleaseNotes.md)
- [Commits](https://github.com/jstedfast/MailKit/compare/4.8.0...4.9.0)

---
updated-dependencies:
- dependency-name: MailKit
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-16 11:24:30 +01:00
dependabot[bot]
1d1155bf0e Bump MimeKit from 4.8.0 to 4.9.0
Bumps [MimeKit](https://github.com/jstedfast/MimeKit) from 4.8.0 to 4.9.0.
- [Changelog](https://github.com/jstedfast/MimeKit/blob/master/ReleaseNotes.md)
- [Commits](https://github.com/jstedfast/MimeKit/compare/4.8.0...4.9.0)

---
updated-dependencies:
- dependency-name: MimeKit
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-16 11:24:24 +01:00
Leendert de Borst
2632211af6 Merge pull request #470 from lanedirt/469-webassembly-required-error-not-visible-in-client-app
Show error if client does not support WebAssembly
2024-12-16 10:34:20 +01:00
Leendert de Borst
05cca6998e Merge pull request #468 from lanedirt/467-task-runner-jobs-do-not-always-run-at-configured-time
Add task runner job table for tracking task runner historic runs
2024-12-16 10:18:13 +01:00
Leendert de Borst
c4a8a20a62 Add E2E test for browser with WASM disabled (#469) 2024-12-15 17:05:31 +01:00
Leendert de Borst
f2c6af9ccb Update install.sh URL comment (#469) 2024-12-15 16:43:48 +01:00
Leendert de Borst
e94201acda Tweak logo on mobile view auth area (#469) 2024-12-15 16:28:57 +01:00
Leendert de Borst
9e03473208 Show error message when client does not support WebAssembly (#469) 2024-12-15 16:28:41 +01:00
Leendert de Borst
0c5b2fb1da Add task runner job table and manual start button (#467) 2024-12-15 15:59:51 +01:00
Leendert de Borst
a5c4a7618d Update AliasServerDbContext.cs so pragma settings are applied correctly (#467) 2024-12-15 14:53:33 +01:00
Leendert de Borst
70220cecbb Merge pull request #466 from lanedirt/465-prepare-093-release
Update version to 0.9.3
2024-12-13 13:09:33 +01:00
Leendert de Borst
c63faa352f Update version to 0.9.3 (#465) 2024-12-13 13:09:14 +01:00
Leendert de Borst
7e261a05c9 Merge pull request #464 from lanedirt/463-bump-spamokpasswordgenerator-library-to-110
Bump SpamOK.PasswordGenerator version to 1.1.0
2024-12-13 13:01:45 +01:00
Leendert de Borst
545ec5576e Bump SpamOK.PasswordGenerator version to 1.1.0 (#463) 2024-12-13 12:45:05 +01:00
dependabot[bot]
73dcbe5860 Bump the npm_and_yarn group across 2 directories with 1 update
Bumps the npm_and_yarn group with 1 update in the /src/AliasVault.Admin directory: [nanoid](https://github.com/ai/nanoid).
Bumps the npm_and_yarn group with 1 update in the /src/AliasVault.Client directory: [nanoid](https://github.com/ai/nanoid).


Updates `nanoid` from 3.3.7 to 3.3.8
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

Updates `nanoid` from 3.3.7 to 3.3.8
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: nanoid
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-13 12:15:20 +01:00
Leendert de Borst
13917444b9 Merge pull request #461 from lanedirt/460-password-not-correct
Fix account registration username capitalization login bug
2024-12-13 12:15:08 +01:00
dependabot[bot]
119e13a9dd Bump Serilog from 4.1.0 to 4.2.0
Bumps [Serilog](https://github.com/serilog/serilog) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/serilog/serilog/releases)
- [Commits](https://github.com/serilog/serilog/compare/v4.1.0...v4.2.0)

---
updated-dependencies:
- dependency-name: Serilog
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-13 11:50:58 +01:00
dependabot[bot]
7d656e9a9a Bump Microsoft.IdentityModel.JsonWebTokens and Microsoft.IdentityModel.Tokens
Bumps [Microsoft.IdentityModel.JsonWebTokens](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet) and [Microsoft.IdentityModel.Tokens](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet). These dependencies needed to be updated together.

Updates `Microsoft.IdentityModel.JsonWebTokens` from 8.2.1 to 8.3.0
- [Release notes](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/releases)
- [Changelog](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/compare/8.2.1...8.3.0)

Updates `Microsoft.IdentityModel.Tokens` from 8.2.1 to 8.3.0
- [Release notes](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/releases)
- [Changelog](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/compare/8.2.1...8.3.0)

---
updated-dependencies:
- dependency-name: Microsoft.IdentityModel.JsonWebTokens
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: Microsoft.IdentityModel.Tokens
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-13 11:50:50 +01:00
Leendert de Borst
8bd05b5c2e Fix account registration username capitalization login bug (#460) 2024-12-13 11:50:18 +01:00
Leendert de Borst
1e65f14323 Update README.md 2024-12-11 23:30:41 +01:00
Leendert de Borst
2c7543889d Update README.md 2024-12-11 18:24:10 +01:00
Leendert de Borst
63c5483208 Merge pull request #455 from lanedirt/454-update-default-server-settings
Update default server settings
2024-12-05 10:07:07 +01:00
Leendert de Borst
2586d61651 Merge pull request #457 from lanedirt/456-add-task-runner-to-installsh-pull-list
Update install.sh to include task runner to image pull list
2024-12-05 10:07:00 +01:00
Leendert de Borst
c7a32cf0e9 Update install.sh (#456) 2024-12-04 21:32:13 +01:00
Leendert de Borst
46cc6527aa Update default server settings (#454) 2024-12-04 19:17:56 +01:00
Leendert de Borst
ef291bffc1 Merge pull request #452 from lanedirt/451-task-runner-cannot-access-database
Task runner cannot access database
2024-12-04 18:55:42 +01:00
Leendert de Borst
94f6199e27 Update AppInfo.cs (#451) 2024-12-04 18:55:05 +01:00
Leendert de Borst
5ababf3bf3 Update docker-compose.yml (#451) 2024-12-04 18:54:23 +01:00
Leendert de Borst
b47e735e8f Merge pull request #450 from lanedirt/449-publish-new-task-runner-image-to-ghcrio
Publish task runner docker container image
2024-12-04 18:39:34 +01:00
Leendert de Borst
de17303085 Publish task runner docker container image (#449) 2024-12-04 18:39:15 +01:00
Leendert de Borst
635136d257 Merge pull request #448 from lanedirt/447-prepare-090-release
Update version to 0.9.0
2024-12-04 18:31:13 +01:00
Leendert de Borst
832e340b1b Update version to 0.9.0 (#447) 2024-12-04 18:30:48 +01:00
Leendert de Borst
4e0b6b5adf Merge pull request #444 from lanedirt/221-add-aliasvaulttaskservice-for-running-scheduled-background-tasks-such-as-log-cleanups
Add TaskRunner for running scheduled background tasks such as log cleanups
2024-12-04 18:28:26 +01:00
Leendert de Borst
18be105350 Merge pull request #446 from lanedirt/442-update-docker-composeyml-when-running-installsh-update
Always download latest docker compose files on install or update
2024-12-04 18:28:16 +01:00
Leendert de Borst
9bea01fbf8 Update install.sh to always download latest docker compose files on install (#442) 2024-12-04 18:18:28 +01:00
Leendert de Borst
a33fd08cb4 Add TaskRunner to docker compose stack (#221) 2024-12-04 17:57:46 +01:00
Leendert de Borst
25f5660f81 Refactor (#221) 2024-12-04 17:57:46 +01:00
Leendert de Borst
0923936f7c Add TaskRunner maintenance time tests (#221) 2024-12-04 17:57:46 +01:00
Leendert de Borst
3c0905d0b0 Add integration tests for TaskRunner (#221) 2024-12-04 17:57:46 +01:00
Leendert de Borst
97fd3beeaa Add E2E test for server settings admin page (#221) 2024-12-04 17:57:45 +01:00
Leendert de Borst
3195ad86ce Add email cleanup tasks (#221) 2024-12-04 17:57:45 +01:00
Leendert de Borst
d147639a83 Add task runner implementation (#221) 2024-12-04 17:57:45 +01:00
Leendert de Borst
9e0716d32e Add using to db connections (#221) 2024-12-04 17:57:45 +01:00
Leendert de Borst
3a05b1e5c3 Refactor admin to use shared server project (#221) 2024-12-04 17:57:45 +01:00
Leendert de Borst
9628861186 Add AliasVault.Shared.Server project (#221) 2024-12-04 17:57:45 +01:00
Leendert de Borst
2b541dc28d Make TaskRunner compile (#221) 2024-12-04 17:57:45 +01:00
Leendert de Borst
e655dcedb0 Refactor service control in admin, add TaskRunner (#221) 2024-12-04 17:57:45 +01:00
Leendert de Borst
9b8bbebb44 Add server settings page to admin (#221) 2024-12-04 17:57:45 +01:00
Leendert de Borst
bbc99ebf16 Add AliasVault.TaskRunner boilerplate (#221) 2024-12-04 17:57:45 +01:00
Leendert de Borst
23690f4e9b Merge pull request #445 from lanedirt/441-move-custom-port-config-from-docker-composeyml-to-env
Move custom port settings to .env file
2024-12-04 17:57:04 +01:00
Leendert de Borst
6286034a9d Move custom ports to .env (#441) 2024-12-04 17:56:44 +01:00
Leendert de Borst
2ea684061e Merge pull request #440 from lanedirt/349-add-statistics-to-admin
Add statistics to admin
2024-12-04 10:53:23 +01:00
Leendert de Borst
973abc8917 Update sqlite connection string to include WAL mode directly (#349) 2024-12-04 10:00:49 +01:00
Leendert de Borst
65304b0f84 Refactor admin dashboard into separate components (#349) 2024-12-03 22:56:22 +01:00
Leendert de Borst
ca4dd89e89 Update AdminPlaywrightTest.cs (#349) 2024-12-02 17:10:15 +01:00
Leendert de Borst
fccf10dc82 Tweak UI of admin account settings (#349) 2024-12-02 17:08:09 +01:00
Leendert de Borst
b845245728 Show amount of emails received in user email claim page (#349) 2024-12-02 16:59:21 +01:00
Leendert de Borst
e46357d603 Add statistics to admin home dashboard page (#349) 2024-12-02 16:54:07 +01:00
Leendert de Borst
6568ed8059 Merge pull request #439 from lanedirt/438-prepare-083-release
Prepare 0.8.3 release
2024-12-02 14:43:15 +01:00
Leendert de Borst
236718c76e Update version to 0.8.3 (#438) 2024-12-02 14:42:54 +01:00
Leendert de Borst
17ef816fa3 Update install docs (#438) 2024-12-02 14:42:45 +01:00
Leendert de Borst
db33a0a1da Merge pull request #437 from lanedirt/436-make-miscrelease-articles-show-up-in-docs
Add gitignore for /docs to include misc/release articles
2024-12-02 14:36:39 +01:00
Leendert de Borst
7a97bbf716 Add gitignore for /docs to include misc/release articles (#436) 2024-12-02 14:36:20 +01:00
Leendert de Borst
0c4ab8c1b6 Merge pull request #435 from lanedirt/434-update-installsh-to-pull-correct-docker-image-version
Update install.sh to pull correct docker image version
2024-12-02 14:32:08 +01:00
Leendert de Borst
6ee19d57bf Merge pull request #433 from lanedirt/dependabot/nuget/main/Swashbuckle.AspNetCore-7.1.0
Bump Swashbuckle.AspNetCore from 7.0.0 to 7.1.0
2024-12-02 13:51:51 +01:00
Leendert de Borst
dcb92c8dad Add update-installer command to install.sh for CI/CD (#434) 2024-12-02 13:41:09 +01:00
Leendert de Borst
968d3cfcf1 Update install.sh to write version to docker-compose to ensure we pull and run the right containers (#434) 2024-12-02 13:32:19 +01:00
Leendert de Borst
8e9c12f6e7 Wait 15 seconds for containers to start during test (#434) 2024-12-02 13:30:15 +01:00
dependabot[bot]
3c8f32e67a Bump Swashbuckle.AspNetCore from 7.0.0 to 7.1.0
Bumps [Swashbuckle.AspNetCore](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) from 7.0.0 to 7.1.0.
- [Release notes](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/releases)
- [Commits](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/compare/v7.0.0...v7.1.0)

---
updated-dependencies:
- dependency-name: Swashbuckle.AspNetCore
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 10:52:44 +00:00
88 changed files with 4884 additions and 465 deletions

View File

@@ -18,23 +18,22 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Create .env file with custom SMTP port as port 25 is not allowed in GitHub Actions
run: |
echo "SMTP_PORT=2525" > .env
- name: Set permissions and run install.sh
run: |
chmod +x install.sh
./install.sh build --verbose
- name: Set up Docker Compose
run: |
sed -i 's/25\:25/2525\:25/g' docker-compose.yml
docker compose -f docker-compose.yml up -d
- name: Test if services are responding
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 5
command: |
sleep 5
sleep 15
# Array of endpoints to test
declare -A endpoints=(

View File

@@ -29,16 +29,17 @@ jobs:
echo "Downloading install script from: $INSTALL_SCRIPT_URL"
curl -f -o install.sh "$INSTALL_SCRIPT_URL"
- name: Create .env file with custom SMTP port as port 25 is not allowed in GitHub Actions
run: |
echo "SMTP_PORT=2525" > .env
- name: Set permissions and run install.sh
run: |
chmod +x install.sh
./install.sh install --verbose
- name: Set up Docker Compose
run: |
# Change the exposed host port of the SmtpService from 25 to 2525 because port 25 is not allowed in GitHub Actions
sed -i 's/25\:25/2525\:25/g' docker-compose.yml
docker compose -f docker-compose.yml up -d
run: docker compose -f docker-compose.yml up -d
- name: Wait for services to be up
run: |

View File

@@ -70,6 +70,14 @@ jobs:
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-smtp:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-smtp:${{ github.ref_name }}
- name: Build and push TaskRunner image
uses: docker/build-push-action@v5
with:
context: .
file: src/Services/AliasVault.TaskRunner/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-task-runner:latest,${{ env.REGISTRY }}/${{ env.REPO_LOWER }}-task-runner:${{ github.ref_name }}
- name: Build and push Reverse Proxy image
uses: docker/build-push-action@v5
with:

View File

@@ -6,9 +6,9 @@
<a href="https://app.aliasvault.net">Live demo 🔥</a> • <a href="https://aliasvault.net?utm_source=gh-readme">Website 🌐</a> • <a href="https://docs.aliasvault.net?utm_source=gh-readme">Documentation 📚</a> • <a href="#installation">Installation ⚙️</a>
</p>
<h3 align="center">
Open-source password and alias manager
</h3>
<p align="center">
<strong>Open-source password and alias manager</strong>
</p>
[<img src="https://img.shields.io/github/v/release/lanedirt/AliasVault?include_prereleases&logo=github">](https://github.com/lanedirt/AliasVault/releases)
[<img src="https://img.shields.io/github/actions/workflow/status/lanedirt/AliasVault/docker-compose-build.yml?label=docker-compose%20build">](https://github.com/lanedirt/AliasVault/actions/workflows/docker-compose-build.yml)
@@ -25,7 +25,7 @@ Open-source password and alias manager
</div>
AliasVault is an open-source password and alias manager built with C# ASP.NET technology. AliasVault can be self-hosted on your own server with Docker, providing a secure and private solution for managing your online identities and passwords.
AliasVault is an end-to-end encrypted password and alias manager that protects your privacy by creating alternative identities, passwords and email addresses for every website you use. The core of AliasVault is built with C# ASP.NET Blazor WASM technology. AliasVault can be self-hosted on your own server with Docker.
### What makes AliasVault unique:
- **Zero-knowledge architecture**: All data is end-to-end encrypted on the client and stored in encrypted state on the server. Your master password never leaves your device and the server never has access to your data.
@@ -69,7 +69,7 @@ The install script will output the URL where the app is available. By default th
- Client: https://localhost
- Admin portal: https://localhost/admin
> Note: If you want to change the default AliasVault ports you can do so in the `docker-compose.yml` file for the `nginx` (reverse-proxy) container.
> Note: If you want to change the default AliasVault ports you can do so in the `.env` file.
## Detailed documentation
For more detailed information about the installation process and other topics, please see the official documentation website:

View File

@@ -67,6 +67,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{DD359F
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Shared.Core", "src\Shared\AliasVault.Shared.Core\AliasVault.Shared.Core.csproj", "{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.TaskRunner", "src\Services\AliasVault.TaskRunner\AliasVault.TaskRunner.csproj", "{D631A936-DD1C-40CC-B735-BD0A5D4F46A1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Shared.Server", "src\Shared\AliasVault.Shared.Server\AliasVault.Shared.Server.csproj", "{34FADEB6-4B56-463B-B359-F844B43D76D9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -169,6 +173,14 @@ Global
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA}.Release|Any CPU.Build.0 = Release|Any CPU
{D631A936-DD1C-40CC-B735-BD0A5D4F46A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D631A936-DD1C-40CC-B735-BD0A5D4F46A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D631A936-DD1C-40CC-B735-BD0A5D4F46A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D631A936-DD1C-40CC-B735-BD0A5D4F46A1}.Release|Any CPU.Build.0 = Release|Any CPU
{34FADEB6-4B56-463B-B359-F844B43D76D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{34FADEB6-4B56-463B-B359-F844B43D76D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{34FADEB6-4B56-463B-B359-F844B43D76D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{34FADEB6-4B56-463B-B359-F844B43D76D9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -197,6 +209,8 @@ Global
{59642CEF-D90A-4A6B-AD3F-9C6300D1E3FC} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
{15EFE0D0-F41B-47D7-86B7-8F840335CB82} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
{40CA41BF-9E67-4D0A-A3F8-38B94992E4CA} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
{D631A936-DD1C-40CC-B735-BD0A5D4F46A1} = {8A477241-B96C-4174-968D-D40CB77F1ECD}
{34FADEB6-4B56-463B-B359-F844B43D76D9} = {DD359F0A-0180-4F8F-9E48-46213386BA4D}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FEE82475-C009-4762-8113-A6563D9DC49E}

View File

@@ -27,4 +27,10 @@ services:
image: aliasvault-smtp
build:
context: .
dockerfile: src/Services/AliasVault.SmtpService/Dockerfile
dockerfile: src/Services/AliasVault.SmtpService/Dockerfile
task-runner:
image: aliasvault-task-runner
build:
context: .
dockerfile: src/Services/AliasVault.TaskRunner/Dockerfile

View File

@@ -2,8 +2,8 @@ services:
reverse-proxy:
image: ghcr.io/lanedirt/aliasvault-reverse-proxy:latest
ports:
- "80:80"
- "443:443"
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
volumes:
- ./certificates/ssl:/etc/nginx/ssl:rw
- ./certificates/letsencrypt:/etc/nginx/ssl-letsencrypt:rw
@@ -35,9 +35,9 @@ services:
- ./database:/database:rw
- ./certificates/app:/certificates/app:rw
- ./logs:/logs:rw
restart: always
env_file:
- .env
restart: always
admin:
image: ghcr.io/lanedirt/aliasvault-admin:latest
@@ -54,11 +54,20 @@ services:
smtp:
image: ghcr.io/lanedirt/aliasvault-smtp:latest
ports:
- "25:25"
- "587:587"
- "${SMTP_PORT:-25}:25"
- "${SMTP_TLS_PORT:-587}:587"
volumes:
- ./database:/database:rw
- ./logs:/logs:rw
restart: always
env_file:
- .env
task-runner:
image: ghcr.io/lanedirt/aliasvault-task-runner:latest
volumes:
- ./database:/database:rw
- ./logs:/logs:rw
restart: always
env_file:
- .env

1
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
!release

View File

@@ -37,7 +37,7 @@ chmod +x install.sh
```bash
./install.sh install
```
> **Note**: AliasVault binds to ports 80 and 443 by default. If you want to change the default AliasVault ports you can do so in the `docker-compose.yml` file for the `reverse-proxy` (nginx) container. Afterwards re-run the `./install.sh install` command to restart the containers with the new port settings.
> **Note**: AliasVault binds to ports 80 and 443 by default. If you want to change the default AliasVault ports you can do so in the `.env` file. Afterwards re-run the `./install.sh install` command to restart the containers with the new port settings.
3. After the script completes, you can access AliasVault at:
- Client: `https://localhost`

View File

@@ -21,6 +21,17 @@ To update to the latest version, run the install script with the `update` option
./install.sh update
```
> Tip: to skip the confirmation prompts and automatically proceed with the update, use the `-y` flag: `./install.sh update -y`
## Updating the installer script
The installer script can check for and apply updates to itself. This is done as part of the `update` command. However you can also update the installer script separately with the `update-installer` command. This is useful if you want to update the installer script without updating AliasVault itself, e.g. as a separate step during CI/CD pipeline.
```bash
./install.sh update-installer
```
> Tip: to skip the confirmation prompts and automatically proceed with the update, use the `-y` flag: `./install.sh update-installer -y`
## Installing a specific version
To install a specific version and skip the automatic version checks, run the install script with the `install` option and specify the version you want to install.

View File

@@ -0,0 +1,19 @@
---
layout: default
title: Create a new release
parent: Release
grand_parent: Miscellaneous
nav_order: 1
---
# Release Preparation Checklist
Follow the steps in the checklist below to prepare a new release.
- [ ] Update ./src/Shared/AliasVault.Shared.Core/AppInfo.cs and update major/minor/patch to the new version. This version will be shown in the client and admin app footer.
- [ ] Update ./install.sh `@version` in header if the install script has changed. This allows the install script to self-update when running ./install.sh update command on default installations.
- [ ] Update README screenshots if applicable
- [ ] Update README current/upcoming features
Optional steps:
- [ ] Update /docs instructions if any changes have been made to the setup process

View File

@@ -0,0 +1,6 @@
---
layout: default
title: Release
parent: Miscellaneous
nav_order: 1
---

View File

@@ -1,11 +1,12 @@
#!/bin/bash
# @version 0.8.2
# @version 0.9.4
# Repository information used for downloading files and images from GitHub
REPO_OWNER="lanedirt"
REPO_NAME="AliasVault"
REPO_BRANCH="main"
GITHUB_RAW_URL="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${REPO_BRANCH}"
GITHUB_RAW_URL_REPO="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}"
GITHUB_RAW_URL_REPO_BRANCH="$GITHUB_RAW_URL_REPO/$REPO_BRANCH"
GITHUB_CONTAINER_REGISTRY="ghcr.io/$(echo "$REPO_OWNER" | tr '[:upper:]' '[:lower:]')/$(echo "$REPO_NAME" | tr '[:upper:]' '[:lower:]')"
# Required files and directories
@@ -37,16 +38,17 @@ show_usage() {
printf "Usage: $0 [COMMAND] [OPTIONS]\n"
printf "\n"
printf "Commands:\n"
printf " install Install AliasVault by pulling pre-built images from GitHub Container Registry (recommended)\n"
printf " uninstall Uninstall AliasVault\n"
printf " update Update AliasVault to the latest version\n"
printf " configure-ssl Configure SSL certificates (Let's Encrypt or self-signed)\n"
printf " configure-email Configure email domains for receiving emails\n"
printf " start Start AliasVault containers\n"
printf " stop Stop AliasVault containers\n"
printf " restart Restart AliasVault containers\n"
printf " reset-password Reset admin password\n"
printf " build Build AliasVault from source (takes longer and requires sufficient specs)\n"
printf " install Install AliasVault by pulling pre-built images from GitHub Container Registry (recommended)\n"
printf " uninstall Uninstall AliasVault\n"
printf " update Update AliasVault to the latest version\n"
printf " update-installer Check and update install.sh script if newer version available\n"
printf " configure-ssl Configure SSL certificates (Let's Encrypt or self-signed)\n"
printf " configure-email Configure email domains for receiving emails\n"
printf " start Start AliasVault containers\n"
printf " stop Stop AliasVault containers\n"
printf " restart Restart AliasVault containers\n"
printf " reset-password Reset admin password\n"
printf " build Build AliasVault from source (takes longer and requires sufficient specs)\n"
printf "\n"
printf "Options:\n"
@@ -115,6 +117,10 @@ parse_args() {
COMMAND="update"
shift
;;
update-installer|cs)
COMMAND="update-installer"
shift
;;
--help)
show_usage
exit 0
@@ -192,6 +198,10 @@ main() {
"update")
handle_update
;;
"update-installer")
check_install_script_update
exit $?
;;
esac
}
@@ -224,37 +234,36 @@ create_directories() {
# Function to initialize workspace
initialize_workspace() {
create_directories
handle_docker_compose
}
# Function to handle docker-compose.yml
handle_docker_compose() {
printf "${CYAN}> Checking docker-compose files...${NC}\n"
local version_tag="$1"
printf "${CYAN}> Downloading latest docker-compose files...${NC}\n"
# Check and download main docker-compose.yml
if [ ! -f "docker-compose.yml" ]; then
printf " ${CYAN}> Downloading docker-compose.yml...${NC}"
if curl -sSf "${GITHUB_RAW_URL}/docker-compose.yml" -o "docker-compose.yml" > /dev/null 2>&1; then
printf "\n ${GREEN}> docker-compose.yml downloaded successfully.${NC}\n"
# Download and overwrite docker-compose.yml
printf " ${GREEN}> Downloading docker-compose.yml for version ${version_tag}...${NC}"
if curl -sSf "${GITHUB_RAW_URL_REPO}/${version_tag}/docker-compose.yml" -o "docker-compose.yml.tmp" > /dev/null 2>&1; then
# Replace the :latest tag with the specific version if provided
if [ -n "$version_tag" ] && [ "$version_tag" != "latest" ]; then
sed "s/:latest/:$version_tag/g" docker-compose.yml.tmp > docker-compose.yml
rm docker-compose.yml.tmp
else
printf "\n ${YELLOW}> Failed to download docker-compose.yml, please check your internet connection and try again. Alternatively, you can download it manually from https://github.com/${REPO_OWNER}/${REPO_NAME}/blob/main/docker-compose.yml and place it in the root directory of AliasVault.${NC}\n"
exit 1
mv docker-compose.yml.tmp docker-compose.yml
fi
printf "\n ${CYAN}> docker-compose.yml downloaded successfully.${NC}\n"
else
printf " ${GREEN}> docker-compose.yml already exists.${NC}\n"
printf "\n ${YELLOW}> Failed to download docker-compose.yml, please check your internet connection and try again. Alternatively, you can download it manually from ${GITHUB_RAW_URL_REPO}/${version_tag}/docker-compose.yml and place it in the root directory of AliasVault.${NC}\n"
exit 1
fi
# Check and download docker-compose.letsencrypt.yml
if [ ! -f "docker-compose.letsencrypt.yml" ]; then
printf " ${CYAN}> Downloading docker-compose.letsencrypt.yml...${NC}"
if curl -sSf "${GITHUB_RAW_URL}/docker-compose.letsencrypt.yml" -o "docker-compose.letsencrypt.yml" > /dev/null 2>&1; then
printf "\n ${GREEN}> docker-compose.letsencrypt.yml downloaded successfully.${NC}\n"
else
printf "\n ${YELLOW}> Failed to download docker-compose.letsencrypt.yml, please check your internet connection and try again. Alternatively, you can download it manually from https://github.com/${REPO_OWNER}/${REPO_NAME}/blob/main/docker-compose.letsencrypt.yml and place it in the root directory of AliasVault.${NC}\n"
exit 1
fi
# Download and overwrite docker-compose.letsencrypt.yml
printf " ${GREEN}> Downloading docker-compose.letsencrypt.yml for version ${version_tag}...${NC}"
if curl -sSf "${GITHUB_RAW_URL_REPO}/${version_tag}/docker-compose.letsencrypt.yml" -o "docker-compose.letsencrypt.yml" > /dev/null 2>&1; then
printf "\n ${CYAN}> docker-compose.letsencrypt.yml downloaded successfully.${NC}\n"
else
printf " ${GREEN}> docker-compose.letsencrypt.yml already exists.${NC}\n"
printf "\n ${YELLOW}> Failed to download docker-compose.letsencrypt.yml, please check your internet connection and try again. Alternatively, you can download it manually from ${GITHUB_RAW_URL_REPO}/${version_tag}/docker-compose.letsencrypt.yml and place it in the root directory of AliasVault.${NC}\n"
exit 1
fi
return 0
@@ -400,6 +409,37 @@ generate_admin_password() {
fi
}
# Function to set default ports
set_default_ports() {
printf "${CYAN}> Checking default ports...${NC}\n"
# Web ports
if ! grep -q "^HTTP_PORT=" "$ENV_FILE" || [ -z "$(grep "^HTTP_PORT=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
update_env_var "HTTP_PORT" "80"
else
printf " ${GREEN}> HTTP_PORT already exists.${NC}\n"
fi
if ! grep -q "^HTTPS_PORT=" "$ENV_FILE" || [ -z "$(grep "^HTTPS_PORT=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
update_env_var "HTTPS_PORT" "443"
else
printf " ${GREEN}> HTTPS_PORT already exists.${NC}\n"
fi
# SMTP ports
if ! grep -q "^SMTP_PORT=" "$ENV_FILE" || [ -z "$(grep "^SMTP_PORT=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
update_env_var "SMTP_PORT" "25"
else
printf " ${GREEN}> SMTP_PORT already exists.${NC}\n"
fi
if ! grep -q "^SMTP_TLS_PORT=" "$ENV_FILE" || [ -z "$(grep "^SMTP_TLS_PORT=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
update_env_var "SMTP_TLS_PORT" "587"
else
printf " ${GREEN}> SMTP_TLS_PORT already exists.${NC}\n"
fi
}
# Helper function to update environment variables
update_env_var() {
local key=$1
@@ -413,6 +453,7 @@ update_env_var() {
printf " ${GREEN}> $key has been set in $ENV_FILE.${NC}\n"
}
# Helper function to delete environment variables
delete_env_var() {
local key=$1
@@ -542,7 +583,7 @@ handle_build() {
printf "Please clone the complete repository using:\n"
printf "git clone https://github.com/${REPO_OWNER}/${REPO_NAME}.git\n"
printf "\n"
printf "Alternatively, you can use '/install' to pull pre-built images.\n"
printf "Alternatively, you can use './install.sh install' to pull pre-built images.\n"
exit 1
fi
@@ -554,6 +595,7 @@ handle_build() {
set_private_email_domains || { printf "${RED}> Failed to set email domains${NC}\n"; exit 1; }
set_smtp_tls_enabled || { printf "${RED}> Failed to set SMTP TLS${NC}\n"; exit 1; }
set_support_email || { printf "${RED}> Failed to set support email${NC}\n"; exit 1; }
set_default_ports || { printf "${RED}> Failed to set default ports${NC}\n"; exit 1; }
# Only generate admin password if not already set
if ! grep -q "^ADMIN_PASSWORD_HASH=" "$ENV_FILE" || [ -z "$(grep "^ADMIN_PASSWORD_HASH=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
@@ -1059,6 +1101,13 @@ handle_update() {
exit 0
fi
if [ "$FORCE_YES" = true ]; then
printf "${CYAN}> Updating AliasVault to the latest version...${NC}\n"
handle_install_version "$latest_version"
printf "${GREEN}> Update completed successfully!${NC}\n"
return
fi
printf "${YELLOW}> A new version of AliasVault is available!${NC}\n"
printf "\n"
printf "${MAGENTA}Important:${NC}\n"
@@ -1118,7 +1167,7 @@ check_install_script_update() {
printf "${CYAN}> Checking for install script updates...${NC}\n"
# Download latest install.sh to temporary file
if ! curl -sSf "${GITHUB_RAW_URL}/install.sh" -o "install.sh.tmp"; then
if ! curl -sSf "${GITHUB_RAW_URL_REPO_BRANCH}/install.sh" -o "install.sh.tmp"; then
printf "${RED}> Failed to check for install script updates. Continuing with current version.${NC}\n"
rm -f install.sh.tmp
return 1
@@ -1160,6 +1209,16 @@ check_install_script_update() {
fi
# If we get here, an update is available
if [ "$FORCE_YES" = true ]; then
printf "${CYAN}> Updating install script...${NC}\n"
cp "install.sh" "install.sh.backup"
mv "install.sh.tmp" "install.sh"
chmod +x "install.sh"
printf "${GREEN}> Install script updated successfully.${NC}\n"
printf "${GREEN}> Backup of previous version saved as install.sh.backup${NC}\n"
exit 0
fi
printf "${YELLOW}> A new version of the install script is available.${NC}\n"
printf "Would you like to update the install script before proceeding? [Y/n]: "
read -r reply
@@ -1206,6 +1265,9 @@ handle_install_version() {
# Initialize workspace which makes sure all required directories and files exist
initialize_workspace
# Update docker-compose files with correct version so we pull the correct images
handle_docker_compose "$target_version"
# Initialize environment
create_env_file || { printf "${RED}> Failed to create .env file${NC}\n"; exit 1; }
populate_hostname || { printf "${RED}> Failed to set hostname${NC}\n"; exit 1; }
@@ -1214,6 +1276,7 @@ handle_install_version() {
set_private_email_domains || { printf "${RED}> Failed to set email domains${NC}\n"; exit 1; }
set_smtp_tls_enabled || { printf "${RED}> Failed to set SMTP TLS${NC}\n"; exit 1; }
set_support_email || { printf "${RED}> Failed to set support email${NC}\n"; exit 1; }
set_default_ports || { printf "${RED}> Failed to set default ports${NC}\n"; exit 1; }
# Only generate admin password if not already set
if ! grep -q "^ADMIN_PASSWORD_HASH=" "$ENV_FILE" || [ -z "$(grep "^ADMIN_PASSWORD_HASH=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
@@ -1226,17 +1289,13 @@ handle_install_version() {
printf "${CYAN}> Installing version: ${target_version}${NC}\n"
local tag="$target_version"
if [ "$target_version" = "latest" ]; then
tag="latest"
fi
images=(
"${GITHUB_CONTAINER_REGISTRY}-reverse-proxy:${tag}"
"${GITHUB_CONTAINER_REGISTRY}-api:${tag}"
"${GITHUB_CONTAINER_REGISTRY}-client:${tag}"
"${GITHUB_CONTAINER_REGISTRY}-admin:${tag}"
"${GITHUB_CONTAINER_REGISTRY}-smtp:${tag}"
"${GITHUB_CONTAINER_REGISTRY}-reverse-proxy:${target_version}"
"${GITHUB_CONTAINER_REGISTRY}-api:${target_version}"
"${GITHUB_CONTAINER_REGISTRY}-client:${target_version}"
"${GITHUB_CONTAINER_REGISTRY}-admin:${target_version}"
"${GITHUB_CONTAINER_REGISTRY}-smtp:${target_version}"
"${GITHUB_CONTAINER_REGISTRY}-task-runner:${target_version}"
)
for image in "${images[@]}"; do

View File

@@ -46,6 +46,7 @@
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
<ProjectReference Include="..\Shared\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj" />
<ProjectReference Include="..\Shared\AliasVault.Shared.Core\AliasVault.Shared.Core.csproj" />
<ProjectReference Include="..\Shared\AliasVault.Shared.Server\AliasVault.Shared.Server.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.Auth\AliasVault.Auth.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />
<ProjectReference Include="..\Utilities\Cryptography\AliasVault.Cryptography.Server\AliasVault.Cryptography.Server.csproj" />

View File

@@ -0,0 +1,240 @@
@using AliasVault.WorkerStatus.Database
@inherits MainBase
@foreach (var service in Services)
{
<button @onclick="() => ServiceClick(service.Name)"
class="@GetServiceButtonClasses(service) mx-3 inline-flex items-center justify-center rounded-xl px-8 py-2 text-white"
disabled="@(!IsHeartbeatValid(service.LastHeartbeat))"
title="@GetButtonTooltip(service.LastHeartbeat)">
<span>@service.DisplayName</span>
@if (service.IsPending)
{
<svg class="animate-spin ml-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
}
</button>
}
@code {
/// <summary>
/// The names of the services to display.
/// </summary>
[Parameter]
public List<string> ServiceNames { get; set; } = ["AliasVault.SmtpService", "AliasVault.TaskRunner"];
/// <summary>
/// The display names of the services to display.
/// </summary>
[Parameter]
public Dictionary<string, string> ServiceDisplayNames { get; set; } = new();
/// <summary>
/// The statuses of the services.
/// </summary>
private List<WorkerServiceStatus> ServiceStatus = [];
/// <summary>
/// Whether the page is initializing.
/// </summary>
private bool InitInProgress;
/// <summary>
/// The interval to refresh the page.
/// </summary>
private readonly int AutoRefreshInterval = 5000;
private CancellationTokenSource? _timerCancellationTokenSource;
/// <summary>
/// The state of a service.
/// </summary>
private sealed class ServiceState
{
public string Name { get; set; } = "";
public string DisplayName { get; set; } = "";
public bool Status { get; set; }
public bool IsPending { get; set; }
public DateTime LastHeartbeat { get; set; }
}
private List<ServiceState> Services { get; set; } = [];
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
Services = ServiceNames.Select(name => new ServiceState
{
Name = name,
DisplayName = ServiceDisplayNames.GetValueOrDefault(name, name)
}).ToList();
await base.OnInitializedAsync();
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
_timerCancellationTokenSource = new CancellationTokenSource();
_ = RunPeriodicRefreshAsync(_timerCancellationTokenSource.Token);
}
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
_timerCancellationTokenSource?.Cancel();
_timerCancellationTokenSource?.Dispose();
}
}
/// <summary>
/// Checks if the heartbeat is valid (within the last 5 minutes).
/// </summary>
private static bool IsHeartbeatValid(DateTime lastHeartbeat)
{
return DateTime.Now <= lastHeartbeat.AddMinutes(5);
}
/// <summary>
/// Gets the CSS classes for a service button based on its current state.
/// </summary>
private static string GetServiceButtonClasses(ServiceState service)
{
string buttonClass = "cursor-pointer ";
if (!IsHeartbeatValid(service.LastHeartbeat))
{
buttonClass += "bg-gray-600";
}
else if (service.Status)
{
buttonClass += "bg-green-600";
}
else
{
buttonClass += "bg-red-600";
}
return buttonClass;
}
/// <summary>
/// Gets the tooltip text for a service button based on its last heartbeat.
/// </summary>
private static string GetButtonTooltip(DateTime lastHeartbeat)
{
return IsHeartbeatValid(lastHeartbeat) ? "" : "Heartbeat offline";
}
/// <summary>
/// Handles a click on a service button.
/// </summary>
private async Task ServiceClick(string serviceName)
{
var service = Services.First(s => s.Name == serviceName);
if (!IsHeartbeatValid(service.LastHeartbeat))
{
return;
}
service.IsPending = true;
StateHasChanged();
service.Status = !service.Status;
await UpdateServiceStatus(serviceName, service.Status);
service.IsPending = false;
StateHasChanged();
}
/// <summary>
/// Initializes the page.
/// </summary>
private async Task InitPage()
{
if (InitInProgress || Services.Any(s => s.IsPending))
{
return;
}
try
{
InitInProgress = true;
var dbContext = await DbContextFactory.CreateDbContextAsync();
ServiceStatus = await dbContext.WorkerServiceStatuses.ToListAsync();
foreach (var service in Services)
{
var entry = ServiceStatus.Find(x => x.ServiceName == service.Name);
if (entry != null)
{
service.LastHeartbeat = entry.Heartbeat;
service.Status = IsHeartbeatValid(service.LastHeartbeat) && entry.CurrentStatus == "Started";
}
}
await InvokeAsync(StateHasChanged);
}
finally
{
InitInProgress = false;
}
}
/// <summary>
/// Updates the status of a service.
/// </summary>
private async Task<bool> UpdateServiceStatus(string serviceName, bool newStatus)
{
var dbContext = await DbContextFactory.CreateDbContextAsync();
var entry = await dbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstOrDefaultAsync();
if (entry != null)
{
string newDesiredStatus = newStatus ? "Started" : "Stopped";
entry.DesiredStatus = newDesiredStatus;
await dbContext.SaveChangesAsync();
var timeout = DateTime.Now.AddSeconds(30);
while (true)
{
if (DateTime.Now > timeout)
{
return false;
}
dbContext = await DbContextFactory.CreateDbContextAsync();
var check = await dbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstAsync();
if (check.CurrentStatus == newDesiredStatus)
{
return true;
}
await Task.Delay(1000);
}
}
return false;
}
/// <summary>
/// Refreshes the service status periodically.
/// </summary>
private async Task RunPeriodicRefreshAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
await InitPage();
await Task.Delay(AutoRefreshInterval, cancellationToken);
}
}
}

View File

@@ -1,209 +0,0 @@
@using AliasVault.WorkerStatus.Database
@inherits MainBase
<button @onclick="SmtpClick"
class="@GetSmtpButtonClasses() mx-3 inline-flex items-center justify-center rounded-xl px-8 py-2 text-white"
disabled="@(!IsHeartbeatValid())"
title="@GetButtonTooltip()">
<span>SmtpService</span>
@if (SmtpPending)
{
<svg class="animate-spin ml-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
}
</button>
@code {
private List<WorkerServiceStatus> ServiceStatus = [];
private bool InitInProgress;
private bool SmtpStatus;
private bool SmtpPending;
private DateTime LastHeartbeat;
/// <summary>
/// The interval in milliseconds for refreshing the service status.
/// </summary>
private readonly int AutoRefreshInterval = 5000;
/// <summary>
/// CancellationTokenSource for the timer.
/// </summary>
private CancellationTokenSource? _timerCancellationTokenSource;
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
_timerCancellationTokenSource = new CancellationTokenSource();
_ = RunPeriodicRefreshAsync(_timerCancellationTokenSource.Token);
}
}
/// <summary>
/// Refreshes the service status periodically while waiting for specified amount of ms in between.
/// </summary>
private async Task RunPeriodicRefreshAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
await InitPage();
await Task.Delay(AutoRefreshInterval, cancellationToken);
}
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
_timerCancellationTokenSource?.Cancel();
_timerCancellationTokenSource?.Dispose();
}
}
/// <summary>
/// Gets the CSS classes for the SMTP button based on its current state.
/// </summary>
/// <returns>A string containing the CSS classes for the button.</returns>
private string GetSmtpButtonClasses()
{
string buttonClass = "cursor-pointer ";
if (!IsHeartbeatValid())
{
buttonClass += "bg-gray-600";
}
else if (SmtpStatus)
{
buttonClass += "bg-green-600";
}
else
{
buttonClass += "bg-red-600";
}
return buttonClass;
}
/// <summary>
/// Gets the tooltip text for the SMTP button.
/// </summary>
/// <returns>A string containing the tooltip text.</returns>
private string GetButtonTooltip()
{
return IsHeartbeatValid() ? "" : "Heartbeat offline";
}
/// <summary>
/// Checks if the heartbeat is valid (within the last 5 minutes).
/// </summary>
/// <returns>True if the heartbeat is valid, false otherwise.</returns>
private bool IsHeartbeatValid()
{
return DateTime.Now <= LastHeartbeat.AddMinutes(5);
}
/// <summary>
/// Handles the click event for the SMTP button.
/// </summary>
private async void SmtpClick()
{
if (!IsHeartbeatValid())
{
return;
}
SmtpPending = true;
StateHasChanged();
SmtpStatus = !SmtpStatus;
await UpdateSmtpStatus(SmtpStatus);
SmtpPending = false;
StateHasChanged();
}
/// <summary>
/// Initializes the page by fetching service statuses and updating the SMTP status.
/// </summary>
private async Task InitPage()
{
if (InitInProgress || SmtpPending)
{
return;
}
try
{
InitInProgress = true;
var dbContext = await DbContextFactory.CreateDbContextAsync();
ServiceStatus = await dbContext.WorkerServiceStatuses.ToListAsync();
var smtpEntry = ServiceStatus.Find(x => x.ServiceName == "AliasVault.SmtpService");
if (smtpEntry != null)
{
LastHeartbeat = smtpEntry.Heartbeat;
SmtpStatus = IsHeartbeatValid() && smtpEntry.CurrentStatus == "Started";
}
await InvokeAsync(StateHasChanged);
}
finally
{
InitInProgress = false;
}
}
/// <summary>
/// Update the service statuses.
/// </summary>
public async Task<bool> UpdateServiceStatus(string serviceName, bool newStatus)
{
// Refresh the DbContext to ensure we get the latest data.
var dbContext = await DbContextFactory.CreateDbContextAsync();
var entry = await dbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstOrDefaultAsync();
if (entry != null)
{
string newDesiredStatus = newStatus ? "Started" : "Stopped";
entry.DesiredStatus = newDesiredStatus;
await dbContext.SaveChangesAsync();
// Wait for service to have updated its status.
var timeout = DateTime.Now.AddSeconds(30);
while (true)
{
if (DateTime.Now > timeout)
{
// Timeout
return false;
}
dbContext = await DbContextFactory.CreateDbContextAsync();
var check = await dbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstAsync();
if (check.CurrentStatus == newDesiredStatus)
{
// Done
return true;
}
await Task.Delay(1000);
}
}
return false;
}
/// <summary>
/// Update the SMTP service status.
/// </summary>
public async Task<bool> UpdateSmtpStatus(bool newStatus)
{
return await UpdateServiceStatus("AliasVault.SmtpService", newStatus);
}
}

View File

@@ -25,12 +25,20 @@
<NavLink href="logging/auth" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
Auth logs
</NavLink>
<NavLink href="settings/server" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
Server settings
</NavLink>
</ul>
</div>
</div>
<div class="flex justify-end items-center lg:order-2">
<Services />
<button id="theme-toggle" data-tooltip-target="tooltip-toggle" type="button" class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5">
<ServiceControl ServiceNames="@(new List<string> { "AliasVault.SmtpService", "AliasVault.TaskRunner" })"
ServiceDisplayNames="@(new Dictionary<string, string>
{
{ "AliasVault.SmtpService", "Smtp" },
{ "AliasVault.TaskRunner", "Tasks" }
})" />
<button id="theme-toggle" data-tooltip-target="tooltip-toggle" type="button" class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5">
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
</button>
@@ -52,7 +60,7 @@
</div>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="userMenuDropdownButton">
<li>
<a href="account/manage" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">Account settings</a>
<a href="account/manage/change-password" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">Account settings</a>
</li>
</ul>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="dropdown">
@@ -99,6 +107,11 @@
Auth logs
</NavLink>
</li>
<li class="block border-b dark:border-gray-700">
<NavLink href="settings/server" class="block py-3 px-4 text-gray-900 lg:py-0 dark:text-white lg:hover:underline lg:px-0" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
Server settings
</NavLink>
</li>
</ul>
</nav>
}

View File

@@ -0,0 +1,49 @@
//-----------------------------------------------------------------------
// <copyright file="UserEmailClaimWithCount.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Admin.Main.Models;
/// <summary>
/// User email claim view model with count.
/// </summary>
public class UserEmailClaimWithCount
{
/// <summary>
/// Gets or sets the id.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the address.
/// </summary>
public string Address { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the address local.
/// </summary>
public string AddressLocal { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the address domain.
/// </summary>
public string AddressDomain { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the created at timestamp.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the updated at timestamp.
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// Gets or sets the email count.
/// </summary>
public int EmailCount { get; set; }
}

View File

@@ -8,7 +8,7 @@
<LayoutPageTitle>Change password</LayoutPageTitle>
<div class="max-w-2xl mx-auto">
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Change password</h3>
<EditForm Model="Input" FormName="change-password" OnValidSubmit="OnValidSubmitAsync" method="post" class="space-y-6">
<DataAnnotationsValidator/>

View File

@@ -1,67 +0,0 @@
@page "/account/manage"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Identity
@inject UserManager<AdminUser> UserManager
<LayoutPageTitle>Profile</LayoutPageTitle>
<div class="max-w-2xl mx-auto">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Profile</h3>
<EditForm Model="Input" FormName="profile" OnValidSubmit="OnValidSubmitAsync" class="space-y-6">
<DataAnnotationsValidator/>
<ValidationSummary class="text-red-600 dark:text-red-400" role="alert"/>
<div>
<label for="username" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Username</label>
<input type="text" value="@username" id="username" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 bg-gray-100 cursor-not-allowed dark:bg-gray-700 dark:border-gray-600 dark:text-gray-400" placeholder="Please choose your username." disabled/>
</div>
<div>
<label for="phone-number" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Phone number</label>
<InputText @bind-Value="Input.PhoneNumber" id="phone-number" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" placeholder="Please enter your phone number."/>
<ValidationMessage For="() => Input.PhoneNumber" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
</div>
<div>
<SubmitButton>Save</SubmitButton>
</div>
</EditForm>
</div>
@code {
private string? username;
private string? phoneNumber;
[SupplyParameterFromForm] private InputModel Input { get; set; } = new();
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
username = await UserManager.GetUserNameAsync(UserService.User());
phoneNumber = await UserManager.GetPhoneNumberAsync(UserService.User());
Input.PhoneNumber ??= phoneNumber;
}
private async Task OnValidSubmitAsync()
{
if (Input.PhoneNumber != phoneNumber)
{
var setPhoneResult = await UserManager.SetPhoneNumberAsync(UserService.User(), Input.PhoneNumber);
if (!setPhoneResult.Succeeded)
{
GlobalNotificationService.AddErrorMessage("Phone number could not be set", true);
}
}
GlobalNotificationService.AddSuccessMessage("Your profile has been updated", true);
}
private sealed class InputModel
{
[Phone]
[Display(Name = "Phone number")]
public string? PhoneNumber { get; set; }
}
}

View File

@@ -1,15 +1,13 @@
@page "/account/manage/2fa"
@using Microsoft.AspNetCore.Identity
@inject UserManager<AdminUser> UserManager
@inject SignInManager<AdminUser> SignInManager
<LayoutPageTitle>Two-factor authentication (2FA)</LayoutPageTitle>
@if (is2FaEnabled)
{
<div class="mx-auto mt-8 p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Two-factor authentication (2FA)</h3>
@if (recoveryCodesLeft == 0)
@@ -41,7 +39,7 @@
</div>
}
<div class="mt-6 p-4 bg-gray-100 dark:bg-gray-700 rounded-lg">
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Authenticator app</h4>
<div class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2">
@if (!hasAuthenticator)

View File

@@ -5,11 +5,10 @@
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="Manage account"
Description="Manage your profile here.">
Description="Manage security settings for the admin account here.">
</PageHeader>
<div class="container mx-auto px-4 py-8">
<hr class="mb-6 border-t border-gray-300"/>
<div class="mx-auto px-4 py-8">
<div class="flex flex-col md:flex-row">
<div class="w-full md:w-1/4 mb-6 md:mb-0">
<ManageNavMenu/>

View File

@@ -4,12 +4,9 @@
<ul class="flex flex-col space-y-1">
<li>
<NavLink href="account/manage" Match="NavLinkMatch.All" class="block px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-150">Profile</NavLink>
<NavLink href="account/manage/change-password" class="block px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-150" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">Password</NavLink>
</li>
<li>
<NavLink href="account/manage/change-password" class="block px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-150">Password</NavLink>
</li>
<li>
<NavLink href="account/manage/2fa" class="block px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-150">Two-factor authentication</NavLink>
<NavLink href="account/manage/2fa" class="block px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-150" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">Two-factor authentication</NavLink>
</li>
</ul>

View File

@@ -0,0 +1,137 @@
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active users</h3>
<button
@onclick="ToggleUserNames"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
@(ShowUserNames ? "Hide names" : "Show names")
</button>
</div>
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 24 hours</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last24Hours</h4>
@if (ShowUserNames)
{
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
<ul>
@foreach (var user in UserStats.Last24HourUsers)
{
<li>@user</li>
}
</ul>
</div>
}
</div>
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 7 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last7Days</h4>
@if (ShowUserNames)
{
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
<ul>
@foreach (var user in UserStats.Last7DayUsers)
{
<li>@user</li>
}
</ul>
</div>
}
</div>
<div class="bg-green-50 dark:bg-green-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 14 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@UserStats.Last14Days</h4>
@if (ShowUserNames)
{
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
<ul>
@foreach (var user in UserStats.Last14DayUsers)
{
<li>@user</li>
}
</ul>
</div>
}
</div>
</div>
}
</div>
@code {
private bool IsLoading { get; set; } = true;
private UserStatistics UserStats { get; set; } = new();
private bool ShowUserNames { get; set; }
/// <summary>
/// Refreshes the data displayed on the card.
/// </summary>
public async Task RefreshData()
{
IsLoading = true;
StateHasChanged();
var now = DateTime.UtcNow;
var last24Hours = now.AddHours(-24);
var last7Days = now.AddDays(-7);
var last14Days = now.AddDays(-14);
// Get user statistics
var (count24h, users24h) = await GetActiveUserCount(last24Hours);
var (count7d, users7d) = await GetActiveUserCount(last7Days);
var (count14d, users14d) = await GetActiveUserCount(last14Days);
UserStats = new UserStatistics
{
Last24Hours = count24h,
Last7Days = count7d,
Last14Days = count14d,
Last24HourUsers = users24h,
Last7DayUsers = users7d,
Last14DayUsers = users14d
};
IsLoading = false;
StateHasChanged();
}
private async Task<(int count, List<string> users)> GetActiveUserCount(DateTime since)
{
// Get unique users who either:
// 1. Have successful auth logs
// 2. Have updated their vault
var activeUsers = await DbContext.AuthLogs
.Where(l => l.Timestamp >= since && l.IsSuccess)
.Select(l => l.Username)
.Union(
DbContext.Vaults
.Where(v => v.UpdatedAt >= since)
.Select(v => v.User.UserName!)
)
.Distinct()
.ToListAsync();
return (activeUsers.Count, activeUsers);
}
private void ToggleUserNames()
{
ShowUserNames = !ShowUserNames;
StateHasChanged();
}
private sealed class UserStatistics
{
public int Last24Hours { get; set; }
public int Last7Days { get; set; }
public int Last14Days { get; set; }
public List<string> Last24HourUsers { get; set; } = new();
public List<string> Last7DayUsers { get; set; } = new();
public List<string> Last14DayUsers { get; set; } = new();
}
}

View File

@@ -0,0 +1,64 @@
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Recent emails received</h3>
</div>
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="bg-primary-50 dark:bg-gray-700/50 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 24 hours</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Last24Hours</h4>
</div>
<div class="bg-primary-50 dark:bg-gray-700/50 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 7 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Last7Days</h4>
</div>
<div class="bg-primary-50 dark:bg-gray-700/50 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 14 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@EmailStats.Last14Days</h4>
</div>
</div>
}
</div>
@code {
private bool IsLoading { get; set; } = true;
private EmailStatistics EmailStats { get; set; } = new();
/// <summary>
/// Refreshes the data displayed on the card.
/// </summary>
public async Task RefreshData()
{
IsLoading = true;
StateHasChanged();
var now = DateTime.UtcNow;
var last24Hours = now.AddHours(-24);
var last7Days = now.AddDays(-7);
var last14Days = now.AddDays(-14);
// Get email statistics
var emailQuery = DbContext.Emails.AsQueryable();
EmailStats = new EmailStatistics
{
Last24Hours = await emailQuery.CountAsync(e => e.DateSystem >= last24Hours),
Last7Days = await emailQuery.CountAsync(e => e.DateSystem >= last7Days),
Last14Days = await emailQuery.CountAsync(e => e.DateSystem >= last14Days)
};
IsLoading = false;
StateHasChanged();
}
private sealed class EmailStatistics
{
public int Last24Hours { get; set; }
public int Last7Days { get; set; }
public int Last14Days { get; set; }
}
}

View File

@@ -0,0 +1,64 @@
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">User registrations</h3>
</div>
@if (IsLoading)
{
<LoadingIndicator />
}
else
{
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="bg-blue-50 dark:bg-blue-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 24 hours</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Last24Hours</h4>
</div>
<div class="bg-blue-50 dark:bg-blue-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 7 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Last7Days</h4>
</div>
<div class="bg-blue-50 dark:bg-blue-900/30 p-4 rounded-lg">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Last 14 days</p>
<h4 class="text-2xl font-bold text-gray-900 dark:text-white">@RegistrationStats.Last14Days</h4>
</div>
</div>
}
</div>
@code {
private bool IsLoading { get; set; } = true;
private RegistrationStatistics RegistrationStats { get; set; } = new();
/// <summary>
/// Refreshes the data displayed on the card.
/// </summary>
public async Task RefreshData()
{
IsLoading = true;
StateHasChanged();
var now = DateTime.UtcNow;
var last24Hours = now.AddHours(-24);
var last7Days = now.AddDays(-7);
var last14Days = now.AddDays(-14);
// Get registration statistics
var registrationQuery = DbContext.AliasVaultUsers.AsQueryable();
RegistrationStats = new RegistrationStatistics
{
Last24Hours = await registrationQuery.CountAsync(u => u.CreatedAt >= last24Hours),
Last7Days = await registrationQuery.CountAsync(u => u.CreatedAt >= last7Days),
Last14Days = await registrationQuery.CountAsync(u => u.CreatedAt >= last14Days)
};
IsLoading = false;
StateHasChanged();
}
private sealed class RegistrationStatistics
{
public int Last24Hours { get; set; }
public int Last7Days { get; set; }
public int Last14Days { get; set; }
}
}

View File

@@ -0,0 +1,60 @@
@page "/"
@using AliasVault.Admin.Main.Pages.Dashboard.Components
@inherits MainBase
<LayoutPageTitle>Home</LayoutPageTitle>
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="AliasVault Admin"
Description="Welcome to the AliasVault admin portal. Below you can find statistics about recent email activity and active users.">
<CustomActions>
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
</CustomActions>
</PageHeader>
<div class="px-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<ActiveUsersCard @ref="_activeUsersCard" />
<RegistrationStatisticsCard @ref="_registrationStatisticsCard" />
<EmailStatisticsCard @ref="_emailStatisticsCard" />
</div>
</div>
@code {
private ActiveUsersCard? _activeUsersCard;
private RegistrationStatisticsCard? _registrationStatisticsCard;
private EmailStatisticsCard? _emailStatisticsCard;
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Check if 2FA is enabled. If not, show a one-time warning on the dashboard.
if (!UserService.User().TwoFactorEnabled)
{
GlobalNotificationService.AddWarningMessage("Two-factor authentication is not enabled. It is recommended to enable it in Account Settings for better security.", true);
}
await RefreshData();
}
}
/// <summary>
/// Refreshes the data displayed on the cards.
/// </summary>
private async Task RefreshData()
{
if (_activeUsersCard != null &&
_registrationStatisticsCard != null &&
_emailStatisticsCard != null)
{
await Task.WhenAll(
_activeUsersCard.RefreshData(),
_registrationStatisticsCard.RefreshData(),
_emailStatisticsCard.RefreshData()
);
}
}
}

View File

@@ -1,21 +0,0 @@
@page "/"
@inherits MainBase
<LayoutPageTitle>Home</LayoutPageTitle>
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="AliasVault Admin"
Description="Welcome to the AliasVault admin portal.">
</PageHeader>
@code {
/// <inheritdoc />
protected override void OnInitialized()
{
base.OnInitialized();
// Redirect to users page.
NavigationService.RedirectTo("users");
}
}

View File

@@ -23,7 +23,7 @@ using Microsoft.JSInterop;
/// Also, a default set of breadcrumbs is added in the parent OnInitialized method.
/// </summary>
[Authorize]
public class MainBase : OwningComponentBase
public abstract class MainBase : OwningComponentBase
{
/// <summary>
/// Gets or sets the NavigationService instance responsible for handling navigation, replaces the default NavigationManager.
@@ -102,18 +102,6 @@ public class MainBase : OwningComponentBase
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Home", Url = NavigationService.BaseUri });
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
// Check if 2FA is enabled. If not, show a persistent notification.
if (!UserService.User().TwoFactorEnabled)
{
GlobalNotificationService.AddWarningMessage("Two-factor authentication is not enabled. Please enable it in Account Settings for better security.");
}
}
/// <summary>
/// Gets the username from the authentication state asynchronously.
/// </summary>

View File

@@ -0,0 +1,102 @@
@using AliasVault.RazorComponents.Tables
@using AliasVault.Shared.Models.Enums
@inherits MainBase
<div class="mb-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
@foreach (var job in JobList)
{
<SortableTableRow>
<SortableTableColumn IsPrimary="true">@job.Id</SortableTableColumn>
<SortableTableColumn>@job.RunDate.ToString("yyyy-MM-dd")</SortableTableColumn>
<SortableTableColumn>@job.StartTime.ToString("HH:mm")</SortableTableColumn>
<SortableTableColumn>@(job.EndTime?.ToString("HH:mm") ?? "-")</SortableTableColumn>
<SortableTableColumn>
@{
string bgColor = job.Status switch
{
TaskRunnerJobStatus.Pending => "bg-yellow-500",
TaskRunnerJobStatus.Running => "bg-blue-500",
TaskRunnerJobStatus.Finished => "bg-green-500",
TaskRunnerJobStatus.Error => "bg-red-500",
_ => "bg-gray-500"
};
}
<span class="px-2 py-1 rounded-full text-white @bgColor">
@job.Status
</span>
</SortableTableColumn>
<SortableTableColumn>@(job.IsOnDemand ? "Yes" : "No")</SortableTableColumn>
<SortableTableColumn Title="@job.ErrorMessage">
@if (!string.IsNullOrEmpty(job.ErrorMessage))
{
<span class="text-red-600 dark:text-red-400">@(job.ErrorMessage.Length > 50 ? job.ErrorMessage[..50] + "..." : job.ErrorMessage)</span>
}
</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
</div>
@code {
private readonly List<TableColumn> _tableColumns =
[
new TableColumn { Title = "ID", PropertyName = "Id" },
new TableColumn { Title = "Date", PropertyName = "RunDate" },
new TableColumn { Title = "Start", PropertyName = "StartTime" },
new TableColumn { Title = "End", PropertyName = "EndTime" },
new TableColumn { Title = "Status", PropertyName = "Status" },
new TableColumn { Title = "On-Demand", PropertyName = "IsOnDemand" },
new TableColumn { Title = "Error", PropertyName = "ErrorMessage" },
];
private List<TaskRunnerJob> JobList { get; set; } = [];
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 5;
private int TotalRecords { get; set; }
private string SortColumn { get; set; } = "Id";
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
/// <summary>
/// Refreshes the data displayed in the table.
/// </summary>
public async Task RefreshData()
{
var dbContext = await DbContextFactory.CreateDbContextAsync();
var query = dbContext.TaskRunnerJobs.AsQueryable();
// Apply sorting
query = SortDirection == SortDirection.Ascending
? query.OrderBy(x => EF.Property<object>(x, SortColumn))
: query.OrderByDescending(x => EF.Property<object>(x, SortColumn));
TotalRecords = await query.CountAsync();
JobList = await query
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToListAsync();
StateHasChanged();
}
/// <inheritdoc/>
protected override async Task OnInitializedAsync()
{
await RefreshData();
}
private async Task HandlePageChanged(int newPage)
{
CurrentPage = newPage;
await RefreshData();
}
private async Task HandleSortChanged((string column, SortDirection direction) sort)
{
SortColumn = sort.column;
SortDirection = sort.direction;
await RefreshData();
}
}

View File

@@ -0,0 +1,166 @@
@page "/settings/server"
@inject ServerSettingsService SettingsService
@inject ILogger<ServerSettingsService> Logger
@using AliasVault.Shared.Models.Enums
@using AliasVault.Shared.Server.Models
@using AliasVault.Shared.Server.Services
@using AliasVault.Admin.Main.Pages.Settings.Components
@inherits MainBase
<LayoutPageTitle>Server settings</LayoutPageTitle>
<PageHeader
BreadcrumbItems="@BreadcrumbItems"
Title="Server settings"
Description="Configure AliasVault server settings.">
<CustomActions>
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
<ConfirmButton OnClick="SaveSettings">Save changes</ConfirmButton>
</CustomActions>
</PageHeader>
<div class="px-4">
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Data Retention</h3>
<div class="grid gap-4 mb-4 sm:grid-cols-2 sm:gap-6 sm:mb-5">
<div>
<label for="generalLogRetention" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">General Log Retention (days)</label>
<input type="number" @bind="Settings.GeneralLogRetentionDays" id="generalLogRetention" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Set to 0 to disable automatic cleanup</p>
</div>
<div>
<label for="authLogRetention" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Auth Log Retention (days)</label>
<input type="number" @bind="Settings.AuthLogRetentionDays" id="authLogRetention" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Set to 0 to disable automatic cleanup</p>
</div>
<div>
<label for="emailRetention" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email Retention (days)</label>
<input type="number" @bind="Settings.EmailRetentionDays" id="emailRetention" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Set to 0 to disable automatic cleanup</p>
</div>
<div>
<label for="maxEmails" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Max Emails per User</label>
<input type="number" @bind="Settings.MaxEmailsPerUser" id="maxEmails" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Set to 0 for unlimited emails</p>
</div>
</div>
</div>
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Maintenance Schedule</h3>
<div class="mb-4">
<label for="schedule" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Time (24h format)</label>
<input type="time" @bind="Settings.MaintenanceTime" id="schedule" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Time when maintenance tasks are run</p>
</div>
<div class="mb-4">
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Run on Days</label>
<div class="flex flex-wrap gap-4">
@foreach (var day in DaysOfWeek)
{
var isSelected = Settings.TaskRunnerDays.Contains(day.Key);
<div class="flex items-center">
<input type="checkbox" checked="@isSelected" @onchange="@(e => ToggleDay(day.Key))" id="@($"day_{day.Key}")" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="@($"day_{day.Key}")" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">@day.Value</label>
</div>
}
</div>
</div>
<div class="mb-4">
<h4 class="mb-2 text-md font-medium text-gray-900 dark:text-white">Manual Execution</h4>
<ConfirmButton OnClick="RunMaintenanceTasksNow">Run Maintenance Tasks Now</ConfirmButton>
</div>
</div>
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Maintenance History</h3>
<TaskRunnerHistory @ref="_taskRunnerHistoryComponent" />
</div>
</div>
@code {
private TaskRunnerHistory? _taskRunnerHistoryComponent;
private ServerSettingsModel Settings { get; set; } = new();
private readonly Dictionary<int, string> DaysOfWeek = new()
{
{ 1, "Monday" },
{ 2, "Tuesday" },
{ 3, "Wednesday" },
{ 4, "Thursday" },
{ 5, "Friday" },
{ 6, "Saturday" },
{ 7, "Sunday" }
};
/// <inheritdoc/>
protected override async Task OnInitializedAsync()
{
Settings = await SettingsService.GetAllSettingsAsync();
}
private void ToggleDay(int day)
{
if (Settings.TaskRunnerDays.Contains(day))
{
Settings.TaskRunnerDays.Remove(day);
}
else
{
Settings.TaskRunnerDays.Add(day);
}
}
private async Task SaveSettings()
{
await SettingsService.SaveSettingsAsync(Settings);
GlobalNotificationService.AddSuccessMessage("Settings saved successfully", true);
}
private async Task RunMaintenanceTasksNow()
{
try
{
var dbContext = await DbContextFactory.CreateDbContextAsync();
var job = new TaskRunnerJob
{
Name = nameof(TaskRunnerJobType.Maintenance),
RunDate = DateTime.Now.Date,
StartTime = TimeOnly.FromDateTime(DateTime.Now),
Status = TaskRunnerJobStatus.Pending,
IsOnDemand = true
};
dbContext.TaskRunnerJobs.Add(job);
await dbContext.SaveChangesAsync();
// Refresh the history component to show the new job
if (_taskRunnerHistoryComponent != null)
{
await _taskRunnerHistoryComponent.RefreshData();
}
Logger.LogWarning("Maintenance tasks manually queued.");
GlobalNotificationService.AddSuccessMessage("Maintenance tasks queued. They will be executed on the next polling cycle (default every minute). Check the logs for details.", true);
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage($"Failed to start maintenance tasks: {ex.Message}", true);
}
}
/// <summary>
/// Refreshes the data displayed on the page.
/// </summary>
private async Task RefreshData()
{
Settings = await SettingsService.GetAllSettingsAsync();
// Refresh the history component to show the new job
if (_taskRunnerHistoryComponent != null)
{
await _taskRunnerHistoryComponent.RefreshData();
}
}
}

View File

@@ -7,6 +7,7 @@
<SortableTableColumn IsPrimary="true">@entry.Id</SortableTableColumn>
<SortableTableColumn>@entry.CreatedAt.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
<SortableTableColumn>@entry.Address</SortableTableColumn>
<SortableTableColumn>@entry.EmailCount</SortableTableColumn>
</SortableTableRow>
}
</SortableTable>
@@ -16,7 +17,7 @@
/// Gets or sets the list of email claims to display.
/// </summary>
[Parameter]
public List<UserEmailClaim> EmailClaimList { get; set; } = [];
public List<UserEmailClaimWithCount> EmailClaimList { get; set; } = [];
private string SortColumn { get; set; } = "CreatedAt";
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
@@ -25,9 +26,10 @@
new TableColumn { Title = "ID", PropertyName = "Id" },
new TableColumn { Title = "Created", PropertyName = "CreatedAt" },
new TableColumn { Title = "Email", PropertyName = "Address" },
new TableColumn { Title = "Email Count", PropertyName = "EmailCount" },
];
private IEnumerable<UserEmailClaim> SortedEmailClaimList => SortList(EmailClaimList, SortColumn, SortDirection);
private IEnumerable<UserEmailClaimWithCount> SortedEmailClaimList => SortList(EmailClaimList, SortColumn, SortDirection);
private void HandleSortChanged((string column, SortDirection direction) sort)
{
@@ -36,13 +38,14 @@
StateHasChanged();
}
private static IEnumerable<UserEmailClaim> SortList(List<UserEmailClaim> emailClaims, string sortColumn, SortDirection sortDirection)
private static IEnumerable<UserEmailClaimWithCount> SortList(List<UserEmailClaimWithCount> emailClaims, string sortColumn, SortDirection sortDirection)
{
return sortColumn switch
{
"Id" => SortableTable.SortListByProperty(emailClaims, e => e.Id, sortDirection),
"CreatedAt" => SortableTable.SortListByProperty(emailClaims, e => e.CreatedAt, sortDirection),
"Address" => SortableTable.SortListByProperty(emailClaims, e => e.Address, sortDirection),
"EmailCount" => SortableTable.SortListByProperty(emailClaims, e => e.EmailCount, sortDirection),
_ => emailClaims
};
}

View File

@@ -97,7 +97,7 @@ else
private int TwoFactorKeysCount { get; set; }
private List<AliasVaultUserRefreshToken> RefreshTokenList { get; set; } = [];
private List<Vault> VaultList { get; set; } = [];
private List<UserEmailClaim> EmailClaimList { get; set; } = [];
private List<UserEmailClaimWithCount> EmailClaimList { get; set; } = [];
/// <inheritdoc />
protected override async Task OnInitializedAsync()
@@ -171,7 +171,18 @@ else
.ToListAsync();
// Load all email claims for this user.
EmailClaimList = await DbContext.UserEmailClaims.Where(x => x.UserId == User.Id)
EmailClaimList = await DbContext.UserEmailClaims
.Where(x => x.UserId == User.Id)
.Select(x => new UserEmailClaimWithCount
{
Id = x.Id,
Address = x.Address,
AddressLocal = x.AddressLocal,
AddressDomain = x.AddressDomain,
CreatedAt = x.CreatedAt,
UpdatedAt = x.UpdatedAt,
EmailCount = DbContext.Emails.Count(e => e.To == x.Address)
})
.OrderBy(x => x.CreatedAt)
.ToListAsync();

View File

@@ -12,6 +12,7 @@
@using AliasVault.Admin
@using AliasVault.Admin.Auth.Components
@using AliasVault.Admin.Main
@using AliasVault.Admin.Main.Layout
@using AliasVault.Admin.Main.Components
@using AliasVault.Admin.Main.Components.Alerts
@using AliasVault.Admin.Main.Components.Layout
@@ -27,4 +28,3 @@
@using AliasVault.Admin.Services
@using AliasServerDb
@using Microsoft.AspNetCore.Authorization

View File

@@ -17,6 +17,7 @@ using AliasVault.Auth;
using AliasVault.Cryptography.Server;
using AliasVault.Logging;
using AliasVault.RazorComponents.Services;
using AliasVault.Shared.Server.Services;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
@@ -53,6 +54,7 @@ builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingAuthenticati
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<AuthLoggingService>();
builder.Services.AddScoped<ConfirmModalService>();
builder.Services.AddScoped<ServerSettingsService>();
builder.Services.AddSingleton(new VersionedContentService(Directory.GetCurrentDirectory() + "/wwwroot"));
builder.Services.AddAuthentication(options =>

View File

@@ -139,10 +139,7 @@ public class GlobalNotificationService
messages.Add(new KeyValuePair<string, string>("error", message));
}
// Clear messages
SuccessMessages.Clear();
InfoMessages.Clear();
ErrorMessages.Clear();
ClearMessages();
return messages;
}

View File

@@ -710,9 +710,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"funding": [
{
"type": "github",

View File

@@ -988,6 +988,14 @@ video {
justify-content: space-between;
}
.gap-4 {
gap: 1rem;
}
.gap-8 {
gap: 2rem;
}
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.25rem * var(--tw-space-x-reverse));
@@ -1258,6 +1266,11 @@ video {
background-color: rgb(251 203 116 / var(--tw-bg-opacity));
}
.bg-primary-50 {
--tw-bg-opacity: 1;
background-color: rgb(255 224 150 / var(--tw-bg-opacity));
}
.bg-primary-500 {
--tw-bg-opacity: 1;
background-color: rgb(244 149 65 / var(--tw-bg-opacity));
@@ -1757,6 +1770,11 @@ video {
color: rgb(154 93 38 / var(--tw-text-opacity));
}
.hover\:text-gray-700:hover {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.hover\:underline:hover {
text-decoration-line: underline;
}
@@ -1952,6 +1970,30 @@ video {
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
}
.dark\:bg-blue-900\/30:is(.dark *) {
background-color: rgb(30 58 138 / 0.3);
}
.dark\:bg-gray-700\/50:is(.dark *) {
background-color: rgb(55 65 81 / 0.5);
}
.dark\:bg-green-900\/30:is(.dark *) {
background-color: rgb(20 83 45 / 0.3);
}
.dark\:bg-blue-950\/80:is(.dark *) {
background-color: rgb(23 37 84 / 0.8);
}
.dark\:bg-gray-800\/80:is(.dark *) {
background-color: rgb(31 41 55 / 0.8);
}
.dark\:bg-green-950\/80:is(.dark *) {
background-color: rgb(5 46 22 / 0.8);
}
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
@@ -2085,6 +2127,11 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:hover\:text-gray-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
}
.dark\:focus\:border-blue-500:focus:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
@@ -2227,6 +2274,10 @@ video {
width: 75%;
}
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.md\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -2268,6 +2319,10 @@ video {
order: 2;
}
.lg\:mb-0 {
margin-bottom: 0px;
}
.lg\:mt-0 {
margin-top: 0px;
}

View File

@@ -23,13 +23,13 @@
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.2.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.3.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.3.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -89,11 +89,8 @@ public class EmailController(ILogger<VaultController> logger, IDbContextFactory<
return errorResult;
}
// Delete associated attachments
context.EmailAttachments.RemoveRange(email!.Attachments);
// Delete the email
context.Emails.Remove(email);
// Delete the email - attachments will be cascade deleted
context.Emails.Remove(email!);
try
{

View File

@@ -1,9 +1,9 @@
<a href="/">
<div class="text-5xl font-bold text-gray-900 dark:text-white mb-4 flex items-center">
<img src="img/logo.svg" alt="AliasVault" class="w-20 h-20 mr-2" />
<span class="relative">
<span class="relative inline-flex flex-wrap items-center">
AliasVault
<span class="absolute -top-2 bg-primary-500 text-white text-xs px-2 py-0.5 rounded-full font-normal">BETA</span>
<span class="ml-2 bg-primary-500 text-white text-xs px-2 py-0.5 rounded-full font-normal sm:absolute sm:-top-2 sm:ml-1">BETA</span>
</span>
</div>
</a>

View File

@@ -8,7 +8,7 @@
<input
id="searchWidget"
type="text"
placeholder="Type here to search"
placeholder="Search for a service..."
autocomplete="off"
class="w-full px-4 py-2 text-gray-700 bg-white border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:focus:ring-primary-500"
@bind-value="SearchTerm"

View File

@@ -19,7 +19,7 @@ using Microsoft.AspNetCore.Components.Authorization;
/// All pages that inherit from this class will receive default injected components that are used globally.
/// Also, a default set of breadcrumbs is added in the parent OnInitialized method.
/// </summary>
public class MainBase : OwningComponentBase
public abstract class MainBase : OwningComponentBase
{
private const string ReturnUrlKey = "returnUrl";
private bool _parametersInitialSet;

View File

@@ -710,9 +710,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"funding": [
{
"type": "github",

View File

@@ -53,6 +53,7 @@
<div class="mt-4 text-center">
<p id="security-quote" class="text-sm text-primary-600 italic"></p>
</div>
<div id="error-message" class="hidden text-red-600 dark:text-red-400 mt-4"></div>
</div>
</div>
</div>
@@ -144,7 +145,7 @@
clearInterval(intervalId);
} else if (elapsedTime % 1000 < checkInterval) {
if (!('WebAssembly' in window)) {
showError("AliasVault requires WebAssembly, which this browser does not support. Please use a modern browser that supports WebAssembly.");
showError("AliasVault requires WebAssembly, which this browser does not support. Try using a more modern browser that supports WebAssembly.");
clearInterval(intervalId);
}
}
@@ -157,7 +158,6 @@
const errorMessageElement = document.getElementById('error-message');
const showError = (message) => {
loadingScreen.querySelector('.inner').classList.add('hidden');
errorMessageElement.textContent = message;
errorMessageElement.classList.remove('hidden');
document.querySelector('.loading-progress-text').classList.add('hidden');
@@ -167,14 +167,14 @@
// Listen for unhandled errors
window.addEventListener('error', function(event) {
if (event.error && event.error.message && event.error.message.includes('WebAssembly')) {
showError("AliasVault requires WebAssembly, which this browser does not support. Please use a modern browser that supports WebAssembly.");
showError("AliasVault requires WebAssembly, which this browser does not support. Try using a more modern browser that supports WebAssembly.");
}
});
// Listen for unhandled promise rejections
window.addEventListener('unhandledrejection', function(event) {
if (event.reason && event.reason.message && event.reason.message.includes('WebAssembly')) {
showError("AliasVault requires WebAssembly, which this browser does not support. Please use a modern browser that supports WebAssembly.");
showError("AliasVault requires WebAssembly, which this browser does not support. Try using a more modern browser that supports WebAssembly.");
}
});

View File

@@ -25,6 +25,7 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
/// </summary>
public AliasServerDbContext()
{
SetPragmaSettings();
}
/// <summary>
@@ -34,6 +35,7 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
public AliasServerDbContext(DbContextOptions<AliasServerDbContext> options)
: base(options)
{
SetPragmaSettings();
}
/// <summary>
@@ -126,6 +128,16 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
/// </summary>
public DbSet<AuthLog> AuthLogs { get; set; }
/// <summary>
/// Gets or sets the ServerSettings DbSet.
/// </summary>
public DbSet<ServerSetting> ServerSettings { get; set; } = null!;
/// <summary>
/// Gets or sets the TaskRunnerJobs DbSet.
/// </summary>
public DbSet<TaskRunnerJob> TaskRunnerJobs { get; set; }
/// <summary>
/// The OnModelCreating method.
/// </summary>
@@ -237,38 +249,46 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
/// <param name="optionsBuilder">DbContextOptionsBuilder instance.</param>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
if (optionsBuilder.IsConfigured)
{
var configuration = new ConfigurationBuilder()
return;
}
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
// Add SQLite connection with enhanced settings
optionsBuilder
.UseSqlite(
configuration.GetConnectionString("AliasServerDbContext") + ";Mode=ReadWriteCreate;Cache=Shared",
options => options.CommandTimeout(60))
.UseLazyLoadingProxies();
// Add SQLite connection with enhanced settings
var connectionString = configuration.GetConnectionString("AliasServerDbContext") +
";Mode=ReadWriteCreate;Cache=Shared";
// Set additional PRAGMA settings
var connection = Database.GetDbConnection();
if (connection.State != System.Data.ConnectionState.Open)
{
connection.Open();
}
optionsBuilder
.UseSqlite(connectionString, options => options.CommandTimeout(60))
.UseLazyLoadingProxies();
}
using (var command = connection.CreateCommand())
{
// Increase busy timeout
command.CommandText = @"
/// <summary>
/// Sets up the PRAGMA settings for SQLite.
/// </summary>
private void SetPragmaSettings()
{
var connection = Database.GetDbConnection();
if (connection.State != System.Data.ConnectionState.Open)
{
connection.Open();
}
using (var command = connection.CreateCommand())
{
// Increase busy timeout
command.CommandText = @"
PRAGMA busy_timeout = 30000;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 1073741824;";
command.ExecuteNonQuery();
}
command.ExecuteNonQuery();
}
}
}

View File

@@ -0,0 +1,848 @@
// <auto-generated />
using System;
using AliasServerDb;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace AliasServerDb.Migrations
{
[DbContext(typeof(AliasServerDbContext))]
[Migration("20241204121218_AddServerSettingsTable")]
partial class AddServerSettingsTable
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true);
modelBuilder.Entity("AliasServerDb.AdminRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AdminRoles");
});
modelBuilder.Entity("AliasServerDb.AdminUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastPasswordChanged")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AdminUsers");
});
modelBuilder.Entity("AliasServerDb.AliasVaultRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AliasVaultRoles");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasColumnType("TEXT");
b.Property<DateTime>("PasswordChangedAt")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AliasVaultUsers");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceIdentifier")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("ExpireDate")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("TEXT");
b.Property<string>("PreviousTokenValue")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AliasVaultUserRefreshTokens");
});
modelBuilder.Entity("AliasServerDb.AuthLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdditionalInfo")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Browser")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Country")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("DeviceType")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("EventType")
.HasColumnType("nvarchar(50)");
b.Property<int?>("FailureReason")
.HasColumnType("INTEGER");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<bool>("IsSuspiciousActivity")
.HasColumnType("INTEGER");
b.Property<string>("OperatingSystem")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("RequestPath")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "EventType" }, "IX_EventType");
b.HasIndex(new[] { "IpAddress" }, "IX_IpAddress");
b.HasIndex(new[] { "Timestamp" }, "IX_Timestamp");
b.HasIndex(new[] { "Username", "IsSuccess", "Timestamp" }, "IX_Username_IsSuccess_Timestamp")
.IsDescending(false, false, true);
b.HasIndex(new[] { "Username", "Timestamp" }, "IX_Username_Timestamp")
.IsDescending(false, true);
b.ToTable("AuthLogs");
});
modelBuilder.Entity("AliasServerDb.Email", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<DateTime>("DateSystem")
.HasColumnType("TEXT");
b.Property<string>("EncryptedSymmetricKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FromDomain")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FromLocal")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MessageHtml")
.HasColumnType("TEXT");
b.Property<string>("MessagePlain")
.HasColumnType("TEXT");
b.Property<string>("MessagePreview")
.HasColumnType("TEXT");
b.Property<string>("MessageSource")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("PushNotificationSent")
.HasColumnType("INTEGER");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("To")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ToDomain")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ToLocal")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("UserEncryptionKeyId")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("Visible")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Date");
b.HasIndex("DateSystem");
b.HasIndex("PushNotificationSent");
b.HasIndex("ToLocal");
b.HasIndex("UserEncryptionKeyId");
b.HasIndex("Visible");
b.ToTable("Emails");
});
modelBuilder.Entity("AliasServerDb.EmailAttachment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("Bytes")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<int>("EmailId")
.HasColumnType("INTEGER");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Filesize")
.HasColumnType("INTEGER");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EmailId");
b.ToTable("EmailAttachments");
});
modelBuilder.Entity("AliasServerDb.Log", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Application")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Exception")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Level")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("LogEvent")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("LogEvent");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MessageTemplate")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Properties")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SourceContext")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("TimeStamp")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Application");
b.HasIndex("TimeStamp");
b.ToTable("Logs", (string)null);
});
modelBuilder.Entity("AliasServerDb.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AddressDomain")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AddressLocal")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Address")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("UserEmailClaims");
});
modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPrimary")
.HasColumnType("INTEGER");
b.Property<string>("PublicKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserEncryptionKeys");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CredentialsCount")
.HasColumnType("INTEGER");
b.Property<int>("EmailClaimsCount")
.HasColumnType("INTEGER");
b.Property<string>("EncryptionSettings")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("FileSize")
.HasColumnType("INTEGER");
b.Property<long>("RevisionNumber")
.HasColumnType("INTEGER");
b.Property<string>("Salt")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("VaultBlob")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Verifier")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Vaults");
});
modelBuilder.Entity("AliasVault.WorkerStatus.Database.WorkerServiceStatus", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CurrentStatus")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("DesiredStatus")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime>("Heartbeat")
.HasColumnType("TEXT");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar");
b.HasKey("Id");
b.ToTable("WorkerServiceStatuses");
});
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("FriendlyName")
.HasColumnType("TEXT");
b.Property<string>("Xml")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("RoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("UserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.ToTable("UserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.ToTable("UserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.Email", b =>
{
b.HasOne("AliasServerDb.UserEncryptionKey", "EncryptionKey")
.WithMany("Emails")
.HasForeignKey("UserEncryptionKeyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("EncryptionKey");
});
modelBuilder.Entity("AliasServerDb.EmailAttachment", b =>
{
b.HasOne("AliasServerDb.Email", "Email")
.WithMany("Attachments")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
});
modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany("EmailClaims")
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany("EncryptionKeys")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany("Vaults")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUser", b =>
{
b.Navigation("EmailClaims");
b.Navigation("EncryptionKeys");
b.Navigation("Vaults");
});
modelBuilder.Entity("AliasServerDb.Email", b =>
{
b.Navigation("Attachments");
});
modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b =>
{
b.Navigation("Emails");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,37 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class AddServerSettingsTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ServerSettings",
columns: table => new
{
Key = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ServerSettings", x => x.Key);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ServerSettings");
}
}
}

View File

@@ -0,0 +1,881 @@
// <auto-generated />
using System;
using AliasServerDb;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace AliasServerDb.Migrations
{
[DbContext(typeof(AliasServerDbContext))]
[Migration("20241215131807_AddTaskRunnerJobTable")]
partial class AddTaskRunnerJobTable
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true);
modelBuilder.Entity("AliasServerDb.AdminRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AdminRoles");
});
modelBuilder.Entity("AliasServerDb.AdminUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastPasswordChanged")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AdminUsers");
});
modelBuilder.Entity("AliasServerDb.AliasVaultRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AliasVaultRoles");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasColumnType("TEXT");
b.Property<DateTime>("PasswordChangedAt")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AliasVaultUsers");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceIdentifier")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("ExpireDate")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("TEXT");
b.Property<string>("PreviousTokenValue")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AliasVaultUserRefreshTokens");
});
modelBuilder.Entity("AliasServerDb.AuthLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdditionalInfo")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Browser")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Country")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("DeviceType")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("EventType")
.HasColumnType("nvarchar(50)");
b.Property<int?>("FailureReason")
.HasColumnType("INTEGER");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSuccess")
.HasColumnType("INTEGER");
b.Property<bool>("IsSuspiciousActivity")
.HasColumnType("INTEGER");
b.Property<string>("OperatingSystem")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("RequestPath")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserAgent")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "EventType" }, "IX_EventType");
b.HasIndex(new[] { "IpAddress" }, "IX_IpAddress");
b.HasIndex(new[] { "Timestamp" }, "IX_Timestamp");
b.HasIndex(new[] { "Username", "IsSuccess", "Timestamp" }, "IX_Username_IsSuccess_Timestamp")
.IsDescending(false, false, true);
b.HasIndex(new[] { "Username", "Timestamp" }, "IX_Username_Timestamp")
.IsDescending(false, true);
b.ToTable("AuthLogs");
});
modelBuilder.Entity("AliasServerDb.Email", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<DateTime>("DateSystem")
.HasColumnType("TEXT");
b.Property<string>("EncryptedSymmetricKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("From")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FromDomain")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FromLocal")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MessageHtml")
.HasColumnType("TEXT");
b.Property<string>("MessagePlain")
.HasColumnType("TEXT");
b.Property<string>("MessagePreview")
.HasColumnType("TEXT");
b.Property<string>("MessageSource")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("PushNotificationSent")
.HasColumnType("INTEGER");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("To")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ToDomain")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ToLocal")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("UserEncryptionKeyId")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("Visible")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Date");
b.HasIndex("DateSystem");
b.HasIndex("PushNotificationSent");
b.HasIndex("ToLocal");
b.HasIndex("UserEncryptionKeyId");
b.HasIndex("Visible");
b.ToTable("Emails");
});
modelBuilder.Entity("AliasServerDb.EmailAttachment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("Bytes")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<int>("EmailId")
.HasColumnType("INTEGER");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Filesize")
.HasColumnType("INTEGER");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EmailId");
b.ToTable("EmailAttachments");
});
modelBuilder.Entity("AliasServerDb.Log", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Application")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Exception")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Level")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("LogEvent")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("LogEvent");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MessageTemplate")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Properties")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SourceContext")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("TimeStamp")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Application");
b.HasIndex("TimeStamp");
b.ToTable("Logs", (string)null);
});
modelBuilder.Entity("AliasServerDb.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("AliasServerDb.TaskRunnerJob", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.HasColumnType("TEXT");
b.Property<bool>("IsOnDemand")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("RunDate")
.HasColumnType("TEXT");
b.Property<TimeOnly>("StartTime")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TaskRunnerJobs");
});
modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AddressDomain")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AddressLocal")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Address")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("UserEmailClaims");
});
modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPrimary")
.HasColumnType("INTEGER");
b.Property<string>("PublicKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserEncryptionKeys");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CredentialsCount")
.HasColumnType("INTEGER");
b.Property<int>("EmailClaimsCount")
.HasColumnType("INTEGER");
b.Property<string>("EncryptionSettings")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("FileSize")
.HasColumnType("INTEGER");
b.Property<long>("RevisionNumber")
.HasColumnType("INTEGER");
b.Property<string>("Salt")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("VaultBlob")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Verifier")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Vaults");
});
modelBuilder.Entity("AliasVault.WorkerStatus.Database.WorkerServiceStatus", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CurrentStatus")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("DesiredStatus")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime>("Heartbeat")
.HasColumnType("TEXT");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar");
b.HasKey("Id");
b.ToTable("WorkerServiceStatuses");
});
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("FriendlyName")
.HasColumnType("TEXT");
b.Property<string>("Xml")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("RoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("UserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.ToTable("UserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.ToTable("UserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("AliasServerDb.AliasVaultUserRefreshToken", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.Email", b =>
{
b.HasOne("AliasServerDb.UserEncryptionKey", "EncryptionKey")
.WithMany("Emails")
.HasForeignKey("UserEncryptionKeyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("EncryptionKey");
});
modelBuilder.Entity("AliasServerDb.EmailAttachment", b =>
{
b.HasOne("AliasServerDb.Email", "Email")
.WithMany("Attachments")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
});
modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany("EmailClaims")
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany("EncryptionKeys")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany("Vaults")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.AliasVaultUser", b =>
{
b.Navigation("EmailClaims");
b.Navigation("EncryptionKeys");
b.Navigation("Vaults");
});
modelBuilder.Entity("AliasServerDb.Email", b =>
{
b.Navigation("Attachments");
});
modelBuilder.Entity("AliasServerDb.UserEncryptionKey", b =>
{
b.Navigation("Emails");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,42 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class AddTaskRunnerJobTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TaskRunnerJobs",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: false),
RunDate = table.Column<DateTime>(type: "TEXT", nullable: false),
StartTime = table.Column<TimeOnly>(type: "TEXT", nullable: false),
EndTime = table.Column<TimeOnly>(type: "TEXT", nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: false),
ErrorMessage = table.Column<string>(type: "TEXT", nullable: true),
IsOnDemand = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TaskRunnerJobs", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TaskRunnerJobs");
}
}
}

View File

@@ -16,7 +16,7 @@ namespace AliasServerDb.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true);
@@ -464,6 +464,59 @@ namespace AliasServerDb.Migrations
b.ToTable("Logs", (string)null);
});
modelBuilder.Entity("AliasServerDb.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSettings");
});
modelBuilder.Entity("AliasServerDb.TaskRunnerJob", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.HasColumnType("TEXT");
b.Property<bool>("IsOnDemand")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("RunDate")
.HasColumnType("TEXT");
b.Property<TimeOnly>("StartTime")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TaskRunnerJobs");
});
modelBuilder.Entity("AliasServerDb.UserEmailClaim", b =>
{
b.Property<Guid>("Id")

View File

@@ -0,0 +1,39 @@
//-----------------------------------------------------------------------
// <copyright file="ServerSetting.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasServerDb;
using System;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Represents a server setting in the AliasServerDb.
/// </summary>
public class ServerSetting
{
/// <summary>
/// Gets or sets the key of the server setting.
/// </summary>
[Key]
[MaxLength(255)]
public string Key { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the value of the server setting.
/// </summary>
public string? Value { get; set; }
/// <summary>
/// Gets or sets the creation date of the server setting.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the update date of the server setting.
/// </summary>
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,62 @@
//-----------------------------------------------------------------------
// <copyright file="TaskRunnerJob.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasServerDb;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using AliasVault.Shared.Models.Enums;
/// <summary>
/// Represents a task runner job entry in the AliasServerDb.
/// </summary>
public class TaskRunnerJob
{
/// <summary>
/// Gets or sets the ID of the task runner job.
/// </summary>
[Key]
public int Id { get; set; }
/// <summary>
/// Gets or sets the name of the task runner job.
/// </summary>
[Required]
[Column(TypeName = "nvarchar(50)")]
public string Name { get; set; } = null!;
/// <summary>
/// Gets or sets the date the job was run.
/// </summary>
public DateTime RunDate { get; set; }
/// <summary>
/// Gets or sets the start time of the job.
/// </summary>
public TimeOnly StartTime { get; set; }
/// <summary>
/// Gets or sets the end time of the job.
/// </summary>
public TimeOnly? EndTime { get; set; }
/// <summary>
/// Gets or sets the status of the job.
/// </summary>
public TaskRunnerJobStatus Status { get; set; }
/// <summary>
/// Gets or sets the error message of the job.
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this is an on-demand run.
/// </summary>
public bool IsOnDemand { get; set; }
}

View File

@@ -24,7 +24,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="SpamOK.PasswordGenerator" Version="1.0.1" />
<PackageReference Include="SpamOK.PasswordGenerator" Version="1.1.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -27,8 +27,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" />
<PackageReference Include="MimeKit" Version="4.8.0" />
<PackageReference Include="NUglify" Version="1.21.10" />
<PackageReference Include="MimeKit" Version="4.9.0" />
<PackageReference Include="NUglify" Version="1.21.11" />
<PackageReference Include="SmtpServer" Version="10.0.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>

View File

@@ -12,7 +12,7 @@ COPY . .
# Build and publish the application
WORKDIR "/src/src/Services/AliasVault.SmtpService"
RUN dotnet publish "./AliasVault.SmtpService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN dotnet publish "./AliasVault.SmtpService.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app

View File

@@ -298,7 +298,7 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config c
}
// Check if the local part of the toAddress is a known alias (claimed by a user)
var dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None);
await using var dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None);
var toAddressLocal = toAddress.User.ToLowerInvariant();
var toAddressDomain = toAddress.Host.ToLowerInvariant();
var userEmailClaim = await dbContext.UserEmailClaims
@@ -348,7 +348,7 @@ public class DatabaseMessageStore(ILogger<DatabaseMessageStore> logger, Config c
/// <param name="userEncryptionKey">The public key of the user to encrypt the mail contents with.</param>
private async Task<int> InsertEmailIntoDatabase(MimeMessage message, MailAddress toAddress, UserEncryptionKey userEncryptionKey)
{
var dbContext = await dbContextFactory.CreateDbContextAsync();
await using var dbContext = await dbContextFactory.CreateDbContextAsync();
var newEmail = ConvertMimeMessageToEmail(message, toAddress);
newEmail = EmailEncryption.EncryptEmail(newEmail, userEncryptionKey);

View File

@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-AliasVault.TaskRunner-eaac287e-32a7-4ff9-bbf9-1925c446ef73</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..\..</DockerfileContext>
<LangVersion>13</LangVersion>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DocumentationFile>bin\Debug\net9.0\AliasVault.TaskRunner.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DocumentationFile>bin\Release\net9.0\AliasVault.TaskRunner.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Databases\AliasServerDb\AliasServerDb.csproj" />
<ProjectReference Include="..\..\Shared\AliasVault.Shared.Server\AliasVault.Shared.Server.csproj" />
<ProjectReference Include="..\..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />
<ProjectReference Include="..\..\Utilities\Cryptography\AliasVault.Cryptography.Server\AliasVault.Cryptography.Server.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Copy the project files and restore dependencies
COPY ["src/Services/AliasVault.TaskRunner/AliasVault.TaskRunner.csproj", "src/Services/AliasVault.TaskRunner/"]
RUN dotnet restore "./src/Services/AliasVault.TaskRunner/AliasVault.TaskRunner.csproj"
COPY . .
# Build and publish the application
WORKDIR "/src/src/Services/AliasVault.TaskRunner"
RUN dotnet publish "./AliasVault.TaskRunner.csproj" -c "$BUILD_CONFIGURATION" -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "AliasVault.TaskRunner.dll"]

View File

@@ -0,0 +1,48 @@
//-----------------------------------------------------------------------
// <copyright file="Program.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
using System.Reflection;
using AliasServerDb;
using AliasServerDb.Configuration;
using AliasVault.Logging;
using AliasVault.Shared.Server.Services;
using AliasVault.TaskRunner.Tasks;
using AliasVault.TaskRunner.Workers;
using AliasVault.WorkerStatus.ServiceExtensions;
using Microsoft.EntityFrameworkCore;
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true);
builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAssembly().GetName().Name!, "../../../logs");
builder.Services.AddAliasVaultSqliteConfiguration();
// -----------------------------------------------------------------------
// Register hosted services via Status library wrapper in order to monitor and control (start/stop) them via the database.
// -----------------------------------------------------------------------
builder.Services.AddSingleton<ServerSettingsService>();
// Define the tasks that will be executed by the TaskRunner.
builder.Services.AddTransient<IMaintenanceTask, LogCleanupTask>();
builder.Services.AddTransient<IMaintenanceTask, RefreshTokenCleanupTask>();
builder.Services.AddTransient<IMaintenanceTask, EmailCleanupTask>();
builder.Services.AddTransient<IMaintenanceTask, EmailQuotaCleanupTask>();
builder.Services.AddStatusHostedService<TaskRunnerWorker, AliasServerDbContext>(Assembly.GetExecutingAssembly().GetName().Name!);
var host = builder.Build();
using (var scope = host.Services.CreateScope())
{
var container = scope.ServiceProvider;
var factory = container.GetRequiredService<IDbContextFactory<AliasServerDbContext>>();
await using var context = await factory.CreateDbContextAsync();
await context.Database.MigrateAsync();
}
await host.RunAsync();

View File

@@ -0,0 +1,17 @@
{
"profiles": {
"AliasVault.TaskRunner": {
"commandName": "Project",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development",
"PRIVATE_EMAIL_DOMAINS": "example.tld",
"SMTP_TLS_ENABLED": "false"
},
"dotnetRunMessages": true
},
"Container (Dockerfile)": {
"commandName": "Docker"
}
},
"$schema": "http://json.schemastore.org/launchsettings.json"
}

View File

@@ -0,0 +1,64 @@
//-----------------------------------------------------------------------
// <copyright file="EmailCleanupTask.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.TaskRunner.Tasks;
using AliasServerDb;
using AliasVault.Shared.Server.Services;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// A maintenance task that deletes old emails based on server settings.
/// </summary>
public class EmailCleanupTask : IMaintenanceTask
{
private readonly ILogger<EmailCleanupTask> _logger;
private readonly IDbContextFactory<AliasServerDbContext> _dbContextFactory;
private readonly ServerSettingsService _settingsService;
/// <summary>
/// Initializes a new instance of the <see cref="EmailCleanupTask"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="settingsService">The settings service.</param>
public EmailCleanupTask(
ILogger<EmailCleanupTask> logger,
IDbContextFactory<AliasServerDbContext> dbContextFactory,
ServerSettingsService settingsService)
{
_logger = logger;
_dbContextFactory = dbContextFactory;
_settingsService = settingsService;
}
/// <inheritdoc />
public string Name => "Email Cleanup";
/// <inheritdoc />
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
var settings = await _settingsService.GetAllSettingsAsync();
if (settings.EmailRetentionDays <= 0)
{
return;
}
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var cutoffDate = DateTime.UtcNow.AddDays(-settings.EmailRetentionDays);
// Delete the emails
var emailsDeleted = await dbContext.Emails
.Where(x => x.DateSystem < cutoffDate)
.ExecuteDeleteAsync(cancellationToken);
_logger.LogWarning(
"Deleted {EmailCount} emails older than {Days} days",
emailsDeleted,
settings.EmailRetentionDays);
}
}

View File

@@ -0,0 +1,102 @@
//-----------------------------------------------------------------------
// <copyright file="EmailQuotaCleanupTask.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.TaskRunner.Tasks;
using AliasServerDb;
using AliasVault.Shared.Server.Services;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// A maintenance task that enforces email quotas by deleting oldest emails when users exceed their limit.
/// </summary>
public class EmailQuotaCleanupTask : IMaintenanceTask
{
private readonly ILogger<EmailQuotaCleanupTask> _logger;
private readonly IDbContextFactory<AliasServerDbContext> _dbContextFactory;
private readonly ServerSettingsService _settingsService;
/// <summary>
/// Initializes a new instance of the <see cref="EmailQuotaCleanupTask"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="settingsService">The settings service.</param>
public EmailQuotaCleanupTask(
ILogger<EmailQuotaCleanupTask> logger,
IDbContextFactory<AliasServerDbContext> dbContextFactory,
ServerSettingsService settingsService)
{
_logger = logger;
_dbContextFactory = dbContextFactory;
_settingsService = settingsService;
}
/// <inheritdoc />
public string Name => "Email Quota Cleanup";
/// <inheritdoc />
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
var settings = await _settingsService.GetAllSettingsAsync();
if (settings.MaxEmailsPerUser <= 0)
{
return;
}
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
// Get all users with their email claims
var userEmailClaims = await dbContext.UserEmailClaims
.Select(c => new { c.UserId, c.Address })
.ToListAsync(cancellationToken);
var totalEmailsDeleted = 0;
var usersProcessed = 0;
// Group email claims by user
foreach (var userGroup in userEmailClaims.GroupBy(c => c.UserId))
{
var userAddresses = userGroup.Select(c => c.Address).ToList();
// Get total email count for this user
var emailCount = await dbContext.Emails
.Where(e => userAddresses.Contains(e.To))
.CountAsync(cancellationToken);
if (emailCount > settings.MaxEmailsPerUser)
{
// Calculate how many emails need to be deleted
var deleteCount = emailCount - settings.MaxEmailsPerUser;
// Delete the oldest emails - attachments will be cascade deleted
var emailsDeleted = await dbContext.Emails
.Where(e => userAddresses.Contains(e.To))
.OrderBy(e => e.DateSystem)
.Take(deleteCount)
.ExecuteDeleteAsync(cancellationToken);
if (emailsDeleted > 0)
{
totalEmailsDeleted += emailsDeleted;
usersProcessed++;
_logger.LogWarning(
"Deleted {EmailCount} emails for user {UserId} to maintain quota of {MaxEmails}",
emailsDeleted,
userGroup.Key,
settings.MaxEmailsPerUser);
}
}
}
_logger.LogWarning(
"Deleted {TotalEmails} emails across {UserCount} users to maintain quota of {MaxEmails} max emails per user",
totalEmailsDeleted,
usersProcessed,
settings.MaxEmailsPerUser);
}
}

View File

@@ -0,0 +1,26 @@
//-----------------------------------------------------------------------
// <copyright file="IMaintenanceTask.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.TaskRunner.Tasks;
/// <summary>
/// Interface for maintenance tasks that can be executed by the TaskRunner.
/// </summary>
public interface IMaintenanceTask
{
/// <summary>
/// Gets the name of the task.
/// </summary>
string Name { get; }
/// <summary>
/// Executes the maintenance task.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task ExecuteAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,73 @@
//-----------------------------------------------------------------------
// <copyright file="LogCleanupTask.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.TaskRunner.Tasks;
using AliasServerDb;
using AliasVault.Shared.Server.Services;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// A maintenance task that deletes old log entries.
/// </summary>
public class LogCleanupTask : IMaintenanceTask
{
private readonly ILogger<LogCleanupTask> _logger;
private readonly IDbContextFactory<AliasServerDbContext> _dbContextFactory;
private readonly ServerSettingsService _settingsService;
/// <summary>
/// Initializes a new instance of the <see cref="LogCleanupTask"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="settingsService">The settings service.</param>
public LogCleanupTask(
ILogger<LogCleanupTask> logger,
IDbContextFactory<AliasServerDbContext> dbContextFactory,
ServerSettingsService settingsService)
{
_logger = logger;
_dbContextFactory = dbContextFactory;
_settingsService = settingsService;
}
/// <inheritdoc />
public string Name => "Log Cleanup";
/// <inheritdoc />
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
var settings = await _settingsService.GetAllSettingsAsync();
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
if (settings.GeneralLogRetentionDays > 0)
{
var cutoffDate = DateTime.UtcNow.AddDays(-settings.GeneralLogRetentionDays);
var deletedCount = await dbContext.Logs
.Where(x => x.TimeStamp < cutoffDate)
.ExecuteDeleteAsync(cancellationToken);
_logger.LogWarning("Deleted {Count} general log entries older than {Days} days", deletedCount, settings.GeneralLogRetentionDays);
// Delete old task runner jobs
var jobCutoffDate = DateTime.UtcNow.AddDays(-settings.GeneralLogRetentionDays);
var deletedJobCount = await dbContext.TaskRunnerJobs
.Where(x => x.RunDate < jobCutoffDate)
.ExecuteDeleteAsync(cancellationToken);
_logger.LogWarning("Deleted {Count} task runner job entries older than {Days} days", deletedJobCount, settings.GeneralLogRetentionDays);
}
if (settings.AuthLogRetentionDays > 0)
{
var cutoffDate = DateTime.UtcNow.AddDays(-settings.AuthLogRetentionDays);
var deletedCount = await dbContext.AuthLogs
.Where(x => x.Timestamp < cutoffDate)
.ExecuteDeleteAsync(cancellationToken);
_logger.LogWarning("Deleted {Count} auth log entries older than {Days} days", deletedCount, settings.AuthLogRetentionDays);
}
}
}

View File

@@ -0,0 +1,52 @@
//-----------------------------------------------------------------------
// <copyright file="RefreshTokenCleanupTask.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.TaskRunner.Tasks;
using AliasServerDb;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// A maintenance task that deletes expired refresh tokens.
/// </summary>
public class RefreshTokenCleanupTask : IMaintenanceTask
{
private readonly ILogger<RefreshTokenCleanupTask> _logger;
private readonly IDbContextFactory<AliasServerDbContext> _dbContextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="RefreshTokenCleanupTask"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="dbContextFactory">The database context factory.</param>
public RefreshTokenCleanupTask(
ILogger<RefreshTokenCleanupTask> logger,
IDbContextFactory<AliasServerDbContext> dbContextFactory)
{
_logger = logger;
_dbContextFactory = dbContextFactory;
}
/// <inheritdoc />
public string Name => "Refresh Token Cleanup";
/// <inheritdoc />
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var cutoffDate = DateTime.UtcNow;
var deletedCount = await dbContext.AliasVaultUserRefreshTokens
.Where(x => x.ExpireDate < cutoffDate)
.ExecuteDeleteAsync(cancellationToken);
if (deletedCount > 0)
{
_logger.LogWarning("Deleted {Count} expired refresh tokens", deletedCount);
}
}
}

View File

@@ -0,0 +1,133 @@
//-----------------------------------------------------------------------
// <copyright file="TaskRunnerWorker.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.TaskRunner.Workers;
using AliasServerDb;
using AliasVault.Shared.Models.Enums;
using AliasVault.Shared.Server.Services;
using AliasVault.TaskRunner.Tasks;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// A worker for the TaskRunner.
/// </summary>
/// <param name="logger">ILogger instance.</param>
/// <param name="tasks">List of maintenance tasks.</param>
/// <param name="settingsService">Server settings service.</param>
/// <param name="dbContextFactory">Database context factory.</param>
public class TaskRunnerWorker(
ILogger<TaskRunnerWorker> logger,
IEnumerable<IMaintenanceTask> tasks,
ServerSettingsService settingsService,
IDbContextFactory<AliasServerDbContext> dbContextFactory) : BackgroundService
{
/// <inheritdoc/>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogWarning("TaskRunnerWorker started at: {Time}", DateTimeOffset.Now);
while (!stoppingToken.IsCancellationRequested)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(stoppingToken);
var settings = await settingsService.GetAllSettingsAsync();
var now = DateTime.Now;
var today = now.Date;
// Check for on-demand run request
var onDemandJob = await dbContext.TaskRunnerJobs
.Where(j => j.IsOnDemand && j.Status == TaskRunnerJobStatus.Pending)
.OrderByDescending(j => j.StartTime)
.FirstOrDefaultAsync(stoppingToken);
if (onDemandJob != null)
{
await ExecuteMaintenanceTasks(onDemandJob, dbContext, stoppingToken);
}
else
{
// Regular scheduled run logic
var scheduledTime = settings.MaintenanceTime;
var currentTime = TimeOnly.FromDateTime(now);
var shouldRunToday = settings.TaskRunnerDays.Contains((int)now.DayOfWeek + 1);
var hasPassedScheduledTime = currentTime >= scheduledTime;
if (shouldRunToday && hasPassedScheduledTime)
{
var existingJob = await dbContext.TaskRunnerJobs
.Where(j => j.Name == nameof(TaskRunnerJobType.Maintenance) && !j.IsOnDemand && j.RunDate.Date == today)
.OrderByDescending(j => j.StartTime)
.FirstOrDefaultAsync(stoppingToken);
if (existingJob == null)
{
var job = new TaskRunnerJob
{
Name = nameof(TaskRunnerJobType.Maintenance),
RunDate = today,
StartTime = TimeOnly.FromDateTime(now),
Status = TaskRunnerJobStatus.Running,
IsOnDemand = false,
};
dbContext.TaskRunnerJobs.Add(job);
await dbContext.SaveChangesAsync(stoppingToken);
await ExecuteMaintenanceTasks(job, dbContext, stoppingToken);
}
}
}
// Check every minute for schedule changes or on-demand requests
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
/// <summary>
/// Executes the maintenance tasks.
/// </summary>
/// <param name="job">The job to execute.</param>
/// <param name="dbContext">The database context.</param>
/// <param name="stoppingToken">The cancellation token.</param>
private async Task ExecuteMaintenanceTasks(TaskRunnerJob job, AliasServerDbContext dbContext, CancellationToken stoppingToken)
{
logger.LogWarning("Starting maintenance tasks at {Time} (On-demand: {IsOnDemand})", DateTime.Now, job.IsOnDemand);
try
{
foreach (var task in tasks)
{
try
{
job.Status = TaskRunnerJobStatus.Running;
await dbContext.SaveChangesAsync(stoppingToken);
await task.ExecuteAsync(stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Error executing task {TaskName}", task.Name);
job.ErrorMessage = $"Task {task.Name} failed: {ex.Message}";
job.Status = TaskRunnerJobStatus.Error;
await dbContext.SaveChangesAsync(stoppingToken);
break;
}
}
if (job.Status != TaskRunnerJobStatus.Error)
{
job.Status = TaskRunnerJobStatus.Finished;
}
}
finally
{
job.EndTime = TimeOnly.FromDateTime(DateTime.Now);
await dbContext.SaveChangesAsync(stoppingToken);
}
logger.LogInformation("Tasks completed with status: {Status}", job.Status);
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
}

View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"ConnectionStrings": {
"AliasServerDbContext": "Data Source=../../../database/AliasServerDb.sqlite"
}
}

View File

@@ -25,12 +25,12 @@ public static class AppInfo
/// <summary>
/// Gets the minor version number.
/// </summary>
public const int VersionMinor = 8;
public const int VersionMinor = 9;
/// <summary>
/// Gets the patch version number.
/// </summary>
public const int VersionPatch = 2;
public const int VersionPatch = 4;
/// <summary>
/// Gets the build number, typically used in CI/CD pipelines.

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DocumentationFile>bin\Debug\net9.0\AliasVault.Shared.Server.xml</DocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DocumentationFile>bin\Release\net9.0\AliasVault.Shared.Server.xml</DocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Databases\AliasServerDb\AliasServerDb.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json">
<Link>stylecop.json</Link>
</AdditionalFiles>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,44 @@
//-----------------------------------------------------------------------
// <copyright file="ServerSettingsModel.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Server.Models;
/// <summary>
/// Server settings model.
/// </summary>
public class ServerSettingsModel
{
/// <summary>
/// Gets or sets the general log retention days. Defaults to 30.
/// </summary>
public int GeneralLogRetentionDays { get; set; } = 30;
/// <summary>
/// Gets or sets the auth log retention days. Defaults to 30.
/// </summary>
public int AuthLogRetentionDays { get; set; } = 30;
/// <summary>
/// Gets or sets the email retention days. Defaults to 0 (unlimited).
/// </summary>
public int EmailRetentionDays { get; set; }
/// <summary>
/// Gets or sets the max emails per user. Defaults to 0 (unlimited).
/// </summary>
public int MaxEmailsPerUser { get; set; }
/// <summary>
/// Gets or sets the time when maintenance tasks are run (24h format). Defaults to 00:00.
/// </summary>
public TimeOnly MaintenanceTime { get; set; } = new(0, 0);
/// <summary>
/// Gets or sets the task runner days. Defaults to all days of the week.
/// </summary>
public List<int> TaskRunnerDays { get; set; } = [1, 2, 3, 4, 5, 6, 7];
}

View File

@@ -0,0 +1,5 @@
# AliasVault.Shared.Server
This project contains shared functionality used only by the server applications and not required by the client applications.
This is to reduce the number of client dependencies and keep the client applications as lightweight as possible.

View File

@@ -0,0 +1,165 @@
//-----------------------------------------------------------------------
// <copyright file="ServerSettingsService.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Server.Services;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using AliasServerDb;
using AliasVault.Shared.Server.Models;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Server settings service.
/// </summary>
/// <param name="dbContextFactory">IDbContextFactory instance.</param>
public class ServerSettingsService(IDbContextFactory<AliasServerDbContext> dbContextFactory)
{
private readonly Dictionary<string, string?> _cache = new();
/// <summary>
/// Gets the setting async.
/// </summary>
/// <param name="key">The key.</param>
/// <returns>The setting.</returns>
public async Task<string?> GetSettingAsync(string key)
{
if (_cache.TryGetValue(key, out var cachedValue))
{
return cachedValue;
}
await using var dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None);
var setting = await dbContext.ServerSettings.FirstOrDefaultAsync(x => x.Key == key);
_cache[key] = setting?.Value;
return setting?.Value;
}
/// <summary>
/// Sets the setting async.
/// </summary>
/// <param name="key">The key.</param>
/// <param name="value">The value.</param>
/// <returns>A task.</returns>
public async Task SetSettingAsync(string key, string? value)
{
// First check if the value is already cached and matches
if (_cache.TryGetValue(key, out var cachedValue) && cachedValue == value)
{
return;
}
await using var dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None);
var setting = await dbContext.ServerSettings.FirstOrDefaultAsync(x => x.Key == key);
var now = DateTime.UtcNow;
// If setting exists and value hasn't changed, return early
if (setting?.Value == value)
{
// Update cache to match database
_cache[key] = value;
return;
}
if (setting == null)
{
setting = new ServerSetting
{
Key = key,
Value = value,
CreatedAt = now,
UpdatedAt = now,
};
dbContext.ServerSettings.Add(setting);
}
else
{
setting.Value = value;
setting.UpdatedAt = now;
}
await dbContext.SaveChangesAsync();
_cache[key] = value;
}
/// <summary>
/// Gets all settings async.
/// </summary>
/// <returns>The settings.</returns>
public async Task<ServerSettingsModel> GetAllSettingsAsync()
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None);
var settings = await dbContext.ServerSettings.ToDictionaryAsync(x => x.Key, x => x.Value);
// Create model with defaults
var model = new ServerSettingsModel();
// Only override if parsing succeeds
if (int.TryParse(settings.GetValueOrDefault("GeneralLogRetentionDays"), out var generalDays))
{
model.GeneralLogRetentionDays = generalDays;
}
if (int.TryParse(settings.GetValueOrDefault("AuthLogRetentionDays"), out var authDays))
{
model.AuthLogRetentionDays = authDays;
}
if (int.TryParse(settings.GetValueOrDefault("EmailRetentionDays"), out var emailDays))
{
model.EmailRetentionDays = emailDays;
}
if (int.TryParse(settings.GetValueOrDefault("MaxEmailsPerUser"), out var maxEmails))
{
model.MaxEmailsPerUser = maxEmails;
}
if (TimeOnly.TryParse(
settings.GetValueOrDefault("MaintenanceTime") ?? "00:00",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var time))
{
model.MaintenanceTime = time;
}
var taskRunnerDaysStr = settings.GetValueOrDefault("TaskRunnerDays");
if (!string.IsNullOrEmpty(taskRunnerDaysStr))
{
try
{
model.TaskRunnerDays = taskRunnerDaysStr.Split(',').Select(int.Parse).ToList();
}
catch (FormatException)
{
// Keep default if parsing fails
}
}
return model;
}
/// <summary>
/// Saves the settings async.
/// </summary>
/// <param name="model">The model.</param>
/// <returns>A task.</returns>
public async Task SaveSettingsAsync(ServerSettingsModel model)
{
await SetSettingAsync("GeneralLogRetentionDays", model.GeneralLogRetentionDays.ToString());
await SetSettingAsync("AuthLogRetentionDays", model.AuthLogRetentionDays.ToString());
await SetSettingAsync("EmailRetentionDays", model.EmailRetentionDays.ToString());
await SetSettingAsync("MaxEmailsPerUser", model.MaxEmailsPerUser.ToString());
await SetSettingAsync("MaintenanceTime", model.MaintenanceTime.ToString("HH:mm", CultureInfo.InvariantCulture));
await SetSettingAsync("TaskRunnerDays", string.Join(",", model.TaskRunnerDays));
}
}

View File

@@ -0,0 +1,34 @@
//-----------------------------------------------------------------------
// <copyright file="TaskRunnerJobStatus.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.Enums;
/// <summary>
/// The status of a task runner job.
/// </summary>
public enum TaskRunnerJobStatus
{
/// <summary>
/// The job is pending.
/// </summary>
Pending = 0,
/// <summary>
/// The job is running.
/// </summary>
Running = 1,
/// <summary>
/// The job has finished.
/// </summary>
Finished = 2,
/// <summary>
/// The job has failed.
/// </summary>
Error = 9,
}

View File

@@ -0,0 +1,19 @@
//-----------------------------------------------------------------------
// <copyright file="TaskRunnerJobType.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.Enums;
/// <summary>
/// The type of a task runner job.
/// </summary>
public enum TaskRunnerJobType
{
/// <summary>
/// The job is pending.
/// </summary>
Maintenance,
}

View File

@@ -30,7 +30,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit" Version="4.3.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.4.0">
<PrivateAssets>all</PrivateAssets>

View File

@@ -109,6 +109,6 @@ public class AdminPlaywrightTest : PlaywrightTest
await WaitForUrlAsync("**", "Users");
var pageContent = await Page.TextContentAsync("body");
Assert.That(pageContent, Does.Contain("This page gives an overview of all registered users and the associated vaults"), "No entry page content visible after logging in to admin app.");
Assert.That(pageContent, Does.Contain("Welcome to the AliasVault admin portal"), "No entry page content visible after logging in to admin app.");
}
}

View File

@@ -0,0 +1,93 @@
//-----------------------------------------------------------------------
// <copyright file="ServerSettingsTests.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.E2ETests.Tests.Admin;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// End-to-end tests for server settings feature.
/// </summary>
[Parallelizable(ParallelScope.Self)]
[Category("AdminTests")]
[TestFixture]
public class ServerSettingsTests : AdminPlaywrightTest
{
/// <summary>
/// Test if mutating server settings works correctly.
/// </summary>
/// <returns>Async task.</returns>
[Test]
public async Task ServerSettingsMutationTest()
{
// Navigate to server settings page
await NavigateBrowser("settings/server");
await WaitForUrlAsync("settings/server", "Server settings");
// Set new values for retention settings
await Page.Locator("input[id='generalLogRetention']").FillAsync("45");
await Page.Locator("input[id='authLogRetention']").FillAsync("120");
await Page.Locator("input[id='emailRetention']").FillAsync("60");
await Page.Locator("input[id='maxEmails']").FillAsync("200");
// Set maintenance time
await Page.Locator("input[id='schedule']").FillAsync("03:30");
// Uncheck Sunday and Saturday from maintenance days
await Page.Locator("input[id='day_7']").UncheckAsync(); // Sunday
await Page.Locator("input[id='day_6']").UncheckAsync(); // Saturday
// Save changes
var saveButton = Page.Locator("text=Save changes");
await saveButton.ClickAsync();
// Wait for success message
await WaitForUrlAsync("settings/server", "Settings saved successfully");
// Verify settings in database
var settings = await DbContext.ServerSettings.ToListAsync();
// Check retention settings
var generalLogRetention = settings.Find(s => s.Key == "GeneralLogRetentionDays");
Assert.That(generalLogRetention?.Value, Is.EqualTo("45"), "General log retention days not saved correctly");
var authLogRetention = settings.Find(s => s.Key == "AuthLogRetentionDays");
Assert.That(authLogRetention?.Value, Is.EqualTo("120"), "Auth log retention days not saved correctly");
var emailRetention = settings.Find(s => s.Key == "EmailRetentionDays");
Assert.That(emailRetention?.Value, Is.EqualTo("60"), "Email retention days not saved correctly");
var maxEmails = settings.Find(s => s.Key == "MaxEmailsPerUser");
Assert.That(maxEmails?.Value, Is.EqualTo("200"), "Max emails per user not saved correctly");
// Check maintenance schedule
var maintenanceTime = settings.Find(s => s.Key == "MaintenanceTime");
Assert.That(maintenanceTime?.Value, Is.EqualTo("03:30"), "Maintenance time not saved correctly");
var taskRunnerDays = settings.Find(s => s.Key == "TaskRunnerDays");
Assert.That(taskRunnerDays?.Value, Is.EqualTo("1,2,3,4,5"), "Task runner days not saved correctly");
// Refresh page and verify values persist
await Page.ReloadAsync();
await WaitForUrlAsync("settings/server", "Server settings");
var generalLogRetentionValue = await Page.Locator("input[id='generalLogRetention']").InputValueAsync();
Assert.That(generalLogRetentionValue, Is.EqualTo("45"), "General log retention value not persisted after refresh");
var maintenanceTimeValue = await Page.Locator("input[id='schedule']").InputValueAsync();
Assert.That(maintenanceTimeValue, Does.Contain("03:30"), "Maintenance time value not persisted after refresh");
// Verify weekend days are still unchecked
var sundayChecked = await Page.Locator("input[id='day_7']").IsCheckedAsync();
var saturdayChecked = await Page.Locator("input[id='day_6']").IsCheckedAsync();
Assert.Multiple(() =>
{
Assert.That(sundayChecked, Is.False, "Sunday checkbox should be unchecked");
Assert.That(saturdayChecked, Is.False, "Saturday checkbox should be unchecked");
});
}
}

View File

@@ -0,0 +1,77 @@
//-----------------------------------------------------------------------
// <copyright file="BrowserWasmTests.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.E2ETests.Tests.Client.Shard3;
using Microsoft.Extensions.Configuration;
using Microsoft.Playwright;
/// <summary>
/// End-to-end tests for user two-factor authentication.
/// </summary>
[Parallelizable(ParallelScope.Self)]
[Category("ClientTests")]
[TestFixture]
public class BrowserWasmTests : ClientPlaywrightTest
{
/// <summary>
/// Test if setting up two-factor authentication and then logging in works.
/// </summary>
/// <returns>Async task.</returns>
[Test]
public async Task ShowsWarningWhenWebAssemblyNotSupported()
{
// Store current browser context and page.
var originalContext = Context;
var originalPage = Page;
try
{
// Create a new browser context and page with WebAssembly disabled to test the error message.
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.Development.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables()
.Build();
bool headless = configuration.GetValue("PlaywrightSettings:Headless", true);
var playwright = await Playwright.CreateAsync();
Browser = await playwright.Chromium.LaunchAsync(new()
{
Args = ["--js-flags=--noexpose-wasm"],
Headless = headless,
});
Context = await Browser.NewContextAsync();
Page = await Context.NewPageAsync();
// Navigate to the app.
await Page.GotoAsync(AppBaseUrl);
// Wait for error message to appear.
var errorMessage = Page.Locator("#error-message");
await errorMessage.WaitForAsync(new LocatorWaitForOptions
{
State = WaitForSelectorState.Visible,
Timeout = 5000,
});
// Verify the error message.
var message = await errorMessage.TextContentAsync();
Assert.That(message, Does.Contain("AliasVault requires WebAssembly"));
}
finally
{
// Clean up the test context and page.
await Page.CloseAsync();
await Context.CloseAsync();
// Restore original context and page for further tests.
Context = originalContext;
Page = originalPage;
}
}
}

View File

@@ -68,11 +68,49 @@ public class AuthTests : ClientPlaywrightTest
}
/// <summary>
/// Test if logging out and logging in works.
/// Test if logging in with different case variations of username works.
/// </summary>
/// <returns>Async task.</returns>
[Test]
[Order(3)]
public async Task CapitalizedUsernameTest()
{
// Logout current user
await Logout();
// Create a new user with capital letters in username
var capitalUsername = "TestUser@Example.com";
await Register(checkForSuccess: true, username: capitalUsername);
await Logout();
// Test Case 1: Try to login with lowercase version of the username
var lowercaseUsername = capitalUsername.ToLower();
await LoginWithUsername(lowercaseUsername);
await VerifySuccessfulLogin();
// Test Case 2: Try to login with exact capitalized username
await Logout();
await LoginWithUsername(capitalUsername);
await VerifySuccessfulLogin();
// Test Case 3: Create new user with lowercase
await Logout();
var lowercaseUser = "testuser2@example.com";
await Register(checkForSuccess: true, username: lowercaseUser);
await Logout();
// Try logging in with uppercase version
var uppercaseVersion = lowercaseUser.ToUpper();
await LoginWithUsername(uppercaseVersion);
await VerifySuccessfulLogin();
}
/// <summary>
/// Test if logging out and logging in works.
/// </summary>
/// <returns>Async task.</returns>
[Test]
[Order(4)]
public async Task LogoutAndLoginRememberMeTest()
{
await Logout();
@@ -101,7 +139,7 @@ public class AuthTests : ClientPlaywrightTest
/// </summary>
/// <returns>Async task.</returns>
[Test]
[Order(4)]
[Order(5)]
public async Task RegisterFormWarningTest()
{
await Logout();
@@ -116,7 +154,7 @@ public class AuthTests : ClientPlaywrightTest
/// </summary>
/// <returns>Async task.</returns>
[Test]
[Order(5)]
[Order(6)]
public async Task PasswordAuthLockoutTest()
{
await Logout();
@@ -152,4 +190,41 @@ public class AuthTests : ClientPlaywrightTest
var pageContent = await Page.TextContentAsync("body");
Assert.That(pageContent, Does.Contain("locked out"), "No account lockout message.");
}
/// <summary>
/// Login with a given username.
/// </summary>
/// <param name="username">The username to login with.</param>
/// <returns>Async task.</returns>
private async Task LoginWithUsername(string username)
{
await NavigateToLogin();
var emailField = await WaitForAndGetElement("input[id='email']");
var passwordField = await WaitForAndGetElement("input[id='password']");
await emailField.FillAsync(username);
await passwordField.FillAsync(TestUserPassword);
var loginButton = await WaitForAndGetElement("button[type='submit']");
await loginButton.ClickAsync();
}
/// <summary>
/// Verify that a login was successful.
/// </summary>
/// <returns>Async task.</returns>
private async Task VerifySuccessfulLogin()
{
// Wait for the index page to load which should show "Credentials" in the top menu.
await WaitForUrlAsync("**", "Credentials");
// Check if the login was successful by verifying content.
var pageContent = await Page.TextContentAsync("body");
Assert.That(pageContent, Does.Contain(WelcomeMessage), "No index content after logging in.");
// Check if login has created an auth log entry.
var authLogEntry = await ApiDbContext.AuthLogs.FirstOrDefaultAsync(x =>
x.EventType == AuthEventType.Login);
Assert.That(authLogEntry, Is.Not.Null, "Auth log entry not found in database after login.");
}
}

View File

@@ -21,9 +21,9 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2"/>
<PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="MailKit" Version="4.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="NUnit" Version="4.2.2"/>
<PackageReference Include="NUnit" Version="4.3.0"/>
<PackageReference Include="NUnit.Analyzers" Version="4.4.0"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
@@ -44,6 +44,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Services\AliasVault.SmtpService\AliasVault.SmtpService.csproj" />
<ProjectReference Include="..\..\Services\AliasVault.TaskRunner\AliasVault.TaskRunner.csproj" />
<ProjectReference Include="..\..\Utilities\Cryptography\AliasVault.Cryptography.Server\AliasVault.Cryptography.Server.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,213 @@
//-----------------------------------------------------------------------
// <copyright file="SeedData.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.IntegrationTests.TaskRunner;
using AliasServerDb;
using AliasVault.Shared.Models.Enums;
/// <summary>
/// Helper class for seeding the database with test data.
/// </summary>
public static class SeedData
{
/// <summary>
/// Seeds the database with test data.
/// </summary>
/// <param name="dbContext">The database context.</param>
/// <returns>Task.</returns>
public static async Task SeedDatabase(AliasServerDbContext dbContext)
{
// Seed the database with settings
var settings = new List<ServerSetting>
{
new() { Key = "EmailRetentionDays", Value = "30" },
new() { Key = "GeneralLogRetentionDays", Value = "45" },
new() { Key = "AuthLogRetentionDays", Value = "60" },
new() { Key = "MaxEmailsPerUser", Value = "100" },
new() { Key = "MaintenanceTime", Value = "00:00" },
new() { Key = "TaskRunnerDays", Value = "1,2,3,4,5,6,7" },
};
await dbContext.ServerSettings.AddRangeAsync(settings);
// Create test user
var user = new AliasVaultUser
{
UserName = "testuser",
Email = "testuser@example.tld",
};
dbContext.AliasVaultUsers.Add(user);
await dbContext.SaveChangesAsync();
// Create encryption key for the user
var encryptionKey = new UserEncryptionKey
{
Id = Guid.NewGuid(),
UserId = user.Id,
PublicKey = "test-encryption-key",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
dbContext.UserEncryptionKeys.Add(encryptionKey);
await dbContext.SaveChangesAsync();
await SeedEmails(dbContext, encryptionKey.Id);
await SeedLogs(dbContext);
await SeedAuthLogs(dbContext);
await dbContext.SaveChangesAsync();
}
/// <summary>
/// Seeds the database with test emails.
/// </summary>
/// <param name="dbContext">The database context.</param>
/// <param name="encryptionKeyId">The encryption key ID.</param>
/// <returns>Task.</returns>
private static async Task SeedEmails(AliasServerDbContext dbContext, Guid encryptionKeyId)
{
// Seed old emails (older than 30 days)
var oldEmails = new List<Email>();
for (int i = 0; i < 50; i++)
{
oldEmails.Add(CreateTestEmail(i, -45, encryptionKeyId, "Old Email"));
}
await dbContext.Emails.AddRangeAsync(oldEmails);
// Seed recent emails (within 30 days)
var recentEmails = new List<Email>();
for (int i = 0; i < 50; i++)
{
recentEmails.Add(CreateTestEmail(i, -1, encryptionKeyId, "Recent Email"));
}
await dbContext.Emails.AddRangeAsync(recentEmails);
}
/// <summary>
/// Seeds the database with test logs.
/// </summary>
/// <param name="dbContext">The database context.</param>
/// <returns>Task.</returns>
private static async Task SeedLogs(AliasServerDbContext dbContext)
{
// Add old general logs (older than 45 days)
var oldLogs = new List<Log>();
for (int i = 0; i < 50; i++)
{
oldLogs.Add(CreateTestLog(i, -60, "Old Log"));
}
await dbContext.Logs.AddRangeAsync(oldLogs);
// Add recent logs (within 45 days)
var recentLogs = new List<Log>();
for (int i = 0; i < 50; i++)
{
recentLogs.Add(CreateTestLog(i, -1, "Recent Log"));
}
await dbContext.Logs.AddRangeAsync(recentLogs);
}
/// <summary>
/// Seeds the database with test auth logs.
/// </summary>
/// <param name="dbContext">The database context.</param>
/// <returns>Task.</returns>
private static async Task SeedAuthLogs(AliasServerDbContext dbContext)
{
// Add old auth logs (older than 60 days)
var oldAuthLogs = new List<AuthLog>();
for (int i = 0; i < 50; i++)
{
oldAuthLogs.Add(CreateTestAuthLog(i, -70));
}
await dbContext.AuthLogs.AddRangeAsync(oldAuthLogs);
// Add recent auth logs (within 60 days)
var recentAuthLogs = new List<AuthLog>();
for (int i = 0; i < 50; i++)
{
recentAuthLogs.Add(CreateTestAuthLog(i, -1));
}
await dbContext.AuthLogs.AddRangeAsync(recentAuthLogs);
}
/// <summary>
/// Creates a test email.
/// </summary>
/// <param name="index">The index.</param>
/// <param name="daysOffset">The days offset.</param>
/// <param name="encryptionKeyId">The encryption key ID.</param>
/// <param name="prefix">The prefix.</param>
/// <returns>Email.</returns>
private static Email CreateTestEmail(int index, int daysOffset, Guid encryptionKeyId, string prefix)
{
return new Email
{
Subject = $"{prefix} {index}",
From = "sender@example.com",
FromLocal = "sender",
FromDomain = "example.com",
To = "testuser@example.tld",
ToLocal = "testuser",
ToDomain = "example.tld",
Date = DateTime.UtcNow.AddDays(daysOffset),
DateSystem = DateTime.UtcNow.AddDays(daysOffset),
MessagePlain = "Test message",
MessagePreview = "Test message",
MessageSource = "Test source",
EncryptedSymmetricKey = "dummy-key",
UserEncryptionKeyId = encryptionKeyId,
};
}
/// <summary>
/// Creates a test log.
/// </summary>
/// <param name="index">The index.</param>
/// <param name="daysOffset">The days offset.</param>
/// <param name="prefix">The prefix.</param>
/// <returns>Log.</returns>
private static Log CreateTestLog(int index, int daysOffset, string prefix)
{
return new Log
{
Application = "TestApp",
SourceContext = "TestContext",
Message = $"{prefix} {index}",
MessageTemplate = $"{prefix} {index}",
Level = "Information",
TimeStamp = DateTime.UtcNow.AddDays(daysOffset),
Exception = string.Empty,
Properties = "{}",
LogEvent = "{}",
};
}
/// <summary>
/// Creates a test auth log.
/// </summary>
/// <param name="index">The index.</param>
/// <param name="daysOffset">The days offset.</param>
/// <returns>AuthLog.</returns>
private static AuthLog CreateTestAuthLog(int index, int daysOffset)
{
return new AuthLog
{
Username = "testuser",
EventType = AuthEventType.Login,
IsSuccess = true,
Timestamp = DateTime.UtcNow.AddDays(daysOffset),
};
}
}

View File

@@ -0,0 +1,191 @@
//-----------------------------------------------------------------------
// <copyright file="TaskRunnerTests.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.IntegrationTests.TaskRunner;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
/// <summary>
/// Integration tests for TaskRunner service.
/// </summary>
[TestFixture]
public class TaskRunnerTests
{
/// <summary>
/// The test host instance.
/// </summary>
private IHost _testHost;
/// <summary>
/// The test host builder instance.
/// </summary>
private TestHostBuilder _testHostBuilder;
/// <summary>
/// Setup logic for every test.
/// </summary>
[SetUp]
public void Setup()
{
_testHostBuilder = new TestHostBuilder();
_testHost = _testHostBuilder.Build();
}
/// <summary>
/// Tear down logic for every test.
/// </summary>
/// <returns>Task.</returns>
[TearDown]
public async Task TearDown()
{
await _testHost.StopAsync();
_testHost.Dispose();
}
/// <summary>
/// Tests the EmailCleanup task.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task EmailCleanup()
{
// Arrange
await InitializeWithTestData();
// Assert
var dbContext = _testHostBuilder.GetDbContext();
var emails = await dbContext.Emails.ToListAsync();
Assert.That(emails, Has.Count.EqualTo(50));
}
/// <summary>
/// Tests the LogCleanup task.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task LogCleanup()
{
// Arrange
await InitializeWithTestData();
// Assert
var dbContext = _testHostBuilder.GetDbContext();
var generalLogs = await dbContext.Logs.ToListAsync();
Assert.That(generalLogs, Has.Count.EqualTo(50), "Only recent general logs should remain");
}
/// <summary>
/// Tests the LogCleanup task.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task AuthLogCleanup()
{
// Arrange
await InitializeWithTestData();
// Assert
var dbContext = _testHostBuilder.GetDbContext();
// Check auth logs
var authLogs = await dbContext.AuthLogs.ToListAsync();
Assert.That(authLogs, Has.Count.EqualTo(50), "Only recent auth logs should remain");
}
/// <summary>
/// Tests that the TaskRunner does not run tasks before the maintenance time.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task MaintenanceTimeInFutureDoesNotRun()
{
// Seed database with generic test data.
await SeedData.SeedDatabase(_testHostBuilder.GetDbContext());
// Update maintenance time in database to future to ensure the task runner doesn't execute yet.
// Get current time and set maintenance time to 2 hours in the future
var now = DateTime.Now;
var futureTime = now.AddHours(2);
// Make sure we don't exceed midnight
if (futureTime.Day != now.Day)
{
futureTime = new DateTime(now.Year, now.Month, now.Day, 23, 59, 5, DateTimeKind.Local);
}
// Update maintenance time in database
var dbContext = _testHostBuilder.GetDbContext();
var maintenanceTimeSetting = await dbContext.ServerSettings
.FirstAsync(s => s.Key == "MaintenanceTime");
maintenanceTimeSetting.Value = futureTime.ToString("HH:mm");
await dbContext.SaveChangesAsync();
// Get initial email count
var initialEmailCount = await dbContext.Emails.CountAsync();
// Start the service.
await _testHost.StartAsync();
// Verify email count hasn't changed (tasks haven't run)
var currentEmailCount = await dbContext.Emails.CountAsync();
Assert.That(currentEmailCount, Is.EqualTo(initialEmailCount), "Email count changed despite maintenance time being in the future. Check if TaskRunner is respecting the maintenance time setting.");
}
/// <summary>
/// Tests that the TaskRunner does not run tasks when the current day is excluded.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task MaintenanceTimeExcludedDayDoesNotRun()
{
// Seed database with generic test data.
await SeedData.SeedDatabase(_testHostBuilder.GetDbContext());
// Get current day of week (1-7, Monday = 1, Sunday = 7)
var currentDay = (int)DateTime.Now.DayOfWeek + 1;
// Update maintenance settings in database to exclude current day
var dbContext = _testHostBuilder.GetDbContext();
// Set maintenance time to midnight
var maintenanceTimeSetting = await dbContext.ServerSettings
.FirstAsync(s => s.Key == "MaintenanceTime");
maintenanceTimeSetting.Value = "00:00";
// Set task runner days to all days except current day
var taskRunnerDays = Enumerable.Range(1, 7)
.Where(d => d != currentDay)
.ToList();
var taskRunnerDaysSetting = await dbContext.ServerSettings
.FirstAsync(s => s.Key == "TaskRunnerDays");
taskRunnerDaysSetting.Value = string.Join(",", taskRunnerDays);
await dbContext.SaveChangesAsync();
// Get initial email count
var initialEmailCount = await dbContext.Emails.CountAsync();
// Start the service
await _testHost.StartAsync();
// Verify email count hasn't changed (tasks haven't run)
var currentEmailCount = await dbContext.Emails.CountAsync();
Assert.That(currentEmailCount, Is.EqualTo(initialEmailCount), "Email count changed despite current day being excluded from maintenance days. Check if TaskRunner is respecting the task runner days setting.");
}
/// <summary>
/// Initializes the test with test data.
/// </summary>
/// <returns>Task.</returns>
protected async Task InitializeWithTestData()
{
await SeedData.SeedDatabase(_testHostBuilder.GetDbContext());
await _testHost.StartAsync();
}
}

View File

@@ -0,0 +1,96 @@
//-----------------------------------------------------------------------
// <copyright file="TestHostBuilder.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.IntegrationTests.TaskRunner;
using System.Data.Common;
using AliasServerDb;
using AliasVault.Shared.Server.Services;
using AliasVault.TaskRunner;
using AliasVault.TaskRunner.Tasks;
using AliasVault.TaskRunner.Workers;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
/// <summary>
/// Builder class for creating a test host for the TaskRunner in order to run integration tests against it.
/// </summary>
public class TestHostBuilder
{
/// <summary>
/// The DbConnection instance that is created for the test.
/// </summary>
private DbConnection? _dbConnection;
private AliasServerDbContext? _dbContext;
/// <summary>
/// Returns the DbContext instance for the test.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public AliasServerDbContext GetDbContext()
{
if (_dbContext == null)
{
var options = new DbContextOptionsBuilder<AliasServerDbContext>()
.UseSqlite(_dbConnection!)
.Options;
_dbContext = new AliasServerDbContext(options);
}
return _dbContext;
}
/// <summary>
/// Builds the TaskRunner test host.
/// </summary>
/// <returns>IHost.</returns>
public IHost Build()
{
// Create a persistent in-memory database for the duration of the test
var dbConnection = new SqliteConnection("DataSource=:memory:");
dbConnection.Open();
_dbConnection = dbConnection;
var builder = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
services.AddSingleton(_dbConnection);
services.AddDbContextFactory<AliasServerDbContext>((sp, options) =>
{
var connection = sp.GetRequiredService<DbConnection>();
options.UseSqlite(connection);
});
// Add server settings service
services.AddSingleton<ServerSettingsService>();
// Add maintenance tasks
services.AddTransient<IMaintenanceTask, LogCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailQuotaCleanupTask>();
// Add the TaskRunner worker
services.AddHostedService<TaskRunnerWorker>();
// Ensure the in-memory database is populated with tables
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AliasServerDbContext>>();
var dbContext = dbContextFactory.CreateDbContext();
dbContext.Database.Migrate();
}
});
return builder.Build();
}
}

View File

@@ -32,7 +32,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit" Version="4.3.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.4.0">
<PrivateAssets>all</PrivateAssets>

View File

@@ -23,9 +23,9 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.SQLite" Version="6.0.0" />

View File

@@ -26,7 +26,9 @@ public static class Srp
/// <returns>SrpSignup model.</returns>
public static SrpPasswordChange PasswordChangeAsync(SrpClient client, string salt, string username, string passwordHashString)
{
// Derive a key from the password using Argon2id
// Derive a key from the password using Argon2id.
// Make sure the username is lowercase as the SRP protocol is case sensitive.
username = username.ToLowerInvariant();
// Signup or password change: client generates a salt and verifier.
var privateKey = DerivePrivateKey(salt, username, passwordHashString);
@@ -44,6 +46,9 @@ public static class Srp
/// <returns>Private key as string.</returns>
public static string DerivePrivateKey(string salt, string username, string passwordHashString)
{
// Make sure the username is lowercase as the SRP protocol is case sensitive.
username = username.ToLowerInvariant();
var client = new SrpClient();
return client.DerivePrivateKey(salt, username, passwordHashString);
}
@@ -80,6 +85,9 @@ public static class Srp
/// <returns>session.</returns>
public static SrpSession DeriveSessionClient(string privateKey, string clientSecretEphemeral, string serverEphemeralPublic, string salt, string username)
{
// Make sure the username is lowercase as the SRP protocol is case sensitive.
username = username.ToLowerInvariant();
var client = new SrpClient();
return client.DeriveSession(
clientSecretEphemeral,
@@ -101,6 +109,9 @@ public static class Srp
/// <returns>SrpSession.</returns>
public static SrpSession? DeriveSessionServer(string serverEphemeralSecret, string clientEphemeralPublic, string salt, string username, string verifier, string clientSessionProof)
{
// Make sure the username is lowercase as the SRP protocol is case sensitive.
username = username.ToLowerInvariant();
try
{
var server = new SrpServer();